dhisana 0.0.1.dev243__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dhisana/__init__.py +1 -0
- dhisana/cli/__init__.py +1 -0
- dhisana/cli/cli.py +20 -0
- dhisana/cli/datasets.py +27 -0
- dhisana/cli/models.py +26 -0
- dhisana/cli/predictions.py +20 -0
- dhisana/schemas/__init__.py +1 -0
- dhisana/schemas/common.py +399 -0
- dhisana/schemas/sales.py +965 -0
- dhisana/ui/__init__.py +1 -0
- dhisana/ui/components.py +472 -0
- dhisana/utils/__init__.py +1 -0
- dhisana/utils/add_mapping.py +352 -0
- dhisana/utils/agent_tools.py +51 -0
- dhisana/utils/apollo_tools.py +1597 -0
- dhisana/utils/assistant_tool_tag.py +4 -0
- dhisana/utils/built_with_api_tools.py +282 -0
- dhisana/utils/cache_output_tools.py +98 -0
- dhisana/utils/cache_output_tools_local.py +78 -0
- dhisana/utils/check_email_validity_tools.py +717 -0
- dhisana/utils/check_for_intent_signal.py +107 -0
- dhisana/utils/check_linkedin_url_validity.py +209 -0
- dhisana/utils/clay_tools.py +43 -0
- dhisana/utils/clean_properties.py +135 -0
- dhisana/utils/company_utils.py +60 -0
- dhisana/utils/compose_salesnav_query.py +259 -0
- dhisana/utils/compose_search_query.py +759 -0
- dhisana/utils/compose_three_step_workflow.py +234 -0
- dhisana/utils/composite_tools.py +137 -0
- dhisana/utils/dataframe_tools.py +237 -0
- dhisana/utils/domain_parser.py +45 -0
- dhisana/utils/email_body_utils.py +72 -0
- dhisana/utils/email_parse_helpers.py +132 -0
- dhisana/utils/email_provider.py +375 -0
- dhisana/utils/enrich_lead_information.py +933 -0
- dhisana/utils/extract_email_content_for_llm.py +101 -0
- dhisana/utils/fetch_openai_config.py +129 -0
- dhisana/utils/field_validators.py +426 -0
- dhisana/utils/g2_tools.py +104 -0
- dhisana/utils/generate_content.py +41 -0
- dhisana/utils/generate_custom_message.py +271 -0
- dhisana/utils/generate_email.py +278 -0
- dhisana/utils/generate_email_response.py +465 -0
- dhisana/utils/generate_flow.py +102 -0
- dhisana/utils/generate_leads_salesnav.py +303 -0
- dhisana/utils/generate_linkedin_connect_message.py +224 -0
- dhisana/utils/generate_linkedin_response_message.py +317 -0
- dhisana/utils/generate_structured_output_internal.py +462 -0
- dhisana/utils/google_custom_search.py +267 -0
- dhisana/utils/google_oauth_tools.py +727 -0
- dhisana/utils/google_workspace_tools.py +1294 -0
- dhisana/utils/hubspot_clearbit.py +96 -0
- dhisana/utils/hubspot_crm_tools.py +2440 -0
- dhisana/utils/instantly_tools.py +149 -0
- dhisana/utils/linkedin_crawler.py +168 -0
- dhisana/utils/lusha_tools.py +333 -0
- dhisana/utils/mailgun_tools.py +156 -0
- dhisana/utils/mailreach_tools.py +123 -0
- dhisana/utils/microsoft365_tools.py +455 -0
- dhisana/utils/openai_assistant_and_file_utils.py +267 -0
- dhisana/utils/openai_helpers.py +977 -0
- dhisana/utils/openapi_spec_to_tools.py +45 -0
- dhisana/utils/openapi_tool/__init__.py +1 -0
- dhisana/utils/openapi_tool/api_models.py +633 -0
- dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +271 -0
- dhisana/utils/openapi_tool/openapi_tool.py +319 -0
- dhisana/utils/parse_linkedin_messages_txt.py +100 -0
- dhisana/utils/profile.py +37 -0
- dhisana/utils/proxy_curl_tools.py +1226 -0
- dhisana/utils/proxycurl_search_leads.py +426 -0
- dhisana/utils/python_function_to_tools.py +83 -0
- dhisana/utils/research_lead.py +176 -0
- dhisana/utils/sales_navigator_crawler.py +1103 -0
- dhisana/utils/salesforce_crm_tools.py +477 -0
- dhisana/utils/search_router.py +131 -0
- dhisana/utils/search_router_jobs.py +51 -0
- dhisana/utils/sendgrid_tools.py +162 -0
- dhisana/utils/serarch_router_local_business.py +75 -0
- dhisana/utils/serpapi_additional_tools.py +290 -0
- dhisana/utils/serpapi_google_jobs.py +117 -0
- dhisana/utils/serpapi_google_search.py +188 -0
- dhisana/utils/serpapi_local_business_search.py +129 -0
- dhisana/utils/serpapi_search_tools.py +852 -0
- dhisana/utils/serperdev_google_jobs.py +125 -0
- dhisana/utils/serperdev_local_business.py +154 -0
- dhisana/utils/serperdev_search.py +233 -0
- dhisana/utils/smtp_email_tools.py +582 -0
- dhisana/utils/test_connect.py +2087 -0
- dhisana/utils/trasform_json.py +173 -0
- dhisana/utils/web_download_parse_tools.py +189 -0
- dhisana/utils/workflow_code_model.py +5 -0
- dhisana/utils/zoominfo_tools.py +357 -0
- dhisana/workflow/__init__.py +1 -0
- dhisana/workflow/agent.py +18 -0
- dhisana/workflow/flow.py +44 -0
- dhisana/workflow/task.py +43 -0
- dhisana/workflow/test.py +90 -0
- dhisana-0.0.1.dev243.dist-info/METADATA +43 -0
- dhisana-0.0.1.dev243.dist-info/RECORD +102 -0
- dhisana-0.0.1.dev243.dist-info/WHEEL +5 -0
- dhisana-0.0.1.dev243.dist-info/entry_points.txt +2 -0
- dhisana-0.0.1.dev243.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2440 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# ─── Standard library ──────────────────────────────────────────────────────────
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
# ─── Third-party packages ──────────────────────────────────────────────────────
|
|
12
|
+
import aiohttp
|
|
13
|
+
from bs4 import BeautifulSoup
|
|
14
|
+
from fastapi import Query
|
|
15
|
+
from markdown import markdown
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
# ─── Internal / application imports ────────────────────────────────────────────
|
|
19
|
+
from dhisana.schemas.sales import HUBSPOT_TO_LEAD_MAPPING, HubSpotLeadInformation
|
|
20
|
+
from dhisana.utils.assistant_tool_tag import assistant_tool
|
|
21
|
+
from dhisana.utils.clean_properties import cleanup_properties
|
|
22
|
+
import logging
|
|
23
|
+
|
|
24
|
+
# --------------------------------------------------------------------
|
|
25
|
+
# 1. Retrieve HubSpot Access Token
|
|
26
|
+
# --------------------------------------------------------------------
|
|
27
|
+
def get_hubspot_access_token(tool_config: Optional[List[Dict]] = None) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Retrieves the HubSpot access token from the provided tool configuration.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If the HubSpot integration has not been configured.
|
|
33
|
+
"""
|
|
34
|
+
if tool_config:
|
|
35
|
+
hubspot_config = next(
|
|
36
|
+
(item for item in tool_config if item.get("name") == "hubspot"), None
|
|
37
|
+
)
|
|
38
|
+
if hubspot_config:
|
|
39
|
+
config_map = {
|
|
40
|
+
item["name"]: item["value"]
|
|
41
|
+
for item in hubspot_config.get("configuration", [])
|
|
42
|
+
if item
|
|
43
|
+
}
|
|
44
|
+
# Check for OAuth access token in nested oauth_tokens structure first, then fall back to API key
|
|
45
|
+
oauth_tokens = config_map.get("oauth_tokens")
|
|
46
|
+
if oauth_tokens and isinstance(oauth_tokens, dict):
|
|
47
|
+
HUBSPOT_ACCESS_TOKEN = oauth_tokens.get("access_token")
|
|
48
|
+
else:
|
|
49
|
+
HUBSPOT_ACCESS_TOKEN = config_map.get("access_token") or config_map.get("apiKey")
|
|
50
|
+
else:
|
|
51
|
+
HUBSPOT_ACCESS_TOKEN = None
|
|
52
|
+
else:
|
|
53
|
+
HUBSPOT_ACCESS_TOKEN = None
|
|
54
|
+
|
|
55
|
+
HUBSPOT_ACCESS_TOKEN = HUBSPOT_ACCESS_TOKEN or os.getenv("HUBSPOT_API_KEY")
|
|
56
|
+
if not HUBSPOT_ACCESS_TOKEN:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"HubSpot integration is not configured. Please configure the connection to HubSpot in Integrations."
|
|
59
|
+
)
|
|
60
|
+
return HUBSPOT_ACCESS_TOKEN
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --------------------------------------------------------------------
|
|
64
|
+
# 2. Search HubSpot Objects (Contacts, Companies, Deals, etc.)
|
|
65
|
+
# with offset, limit, order_by, order
|
|
66
|
+
# --------------------------------------------------------------------
|
|
67
|
+
@assistant_tool
|
|
68
|
+
async def search_hubspot_objects(
|
|
69
|
+
object_type: str,
|
|
70
|
+
offset: int = 0,
|
|
71
|
+
limit: int = 10,
|
|
72
|
+
order_by: Optional[str] = None,
|
|
73
|
+
order: Optional[str] = None,
|
|
74
|
+
filters: Optional[List[Dict[str, Any]]] = None,
|
|
75
|
+
filter_groups: Optional[List[Dict[str, Any]]] = None,
|
|
76
|
+
query: Optional[str] = None,
|
|
77
|
+
properties: Optional[List[str]] = None,
|
|
78
|
+
tool_config: Optional[List[Dict]] = None
|
|
79
|
+
):
|
|
80
|
+
"""
|
|
81
|
+
Search for HubSpot objects (contacts, companies, deals, tickets, etc.) using filters, filter groups, or query.
|
|
82
|
+
Now supports offset, limit, order_by, and order using repeated calls to the V3 search endpoint.
|
|
83
|
+
"""
|
|
84
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
85
|
+
if not object_type:
|
|
86
|
+
return {'error': "HubSpot object type must be provided"}
|
|
87
|
+
|
|
88
|
+
# We need at least one of filters, filter_groups, or query
|
|
89
|
+
if not any([filters, filter_groups, query]):
|
|
90
|
+
return {'error': "At least one of filters, filter_groups, or query must be provided"}
|
|
91
|
+
|
|
92
|
+
headers = {
|
|
93
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
94
|
+
"Content-Type": "application/json"
|
|
95
|
+
}
|
|
96
|
+
url = f"https://api.hubapi.com/crm/v3/objects/{object_type}/search"
|
|
97
|
+
|
|
98
|
+
# Build the base payload
|
|
99
|
+
base_payload: Dict[str, Any] = {}
|
|
100
|
+
if filters:
|
|
101
|
+
base_payload["filterGroups"] = [{"filters": filters}]
|
|
102
|
+
if filter_groups:
|
|
103
|
+
base_payload["filterGroups"] = filter_groups
|
|
104
|
+
if query:
|
|
105
|
+
base_payload["query"] = query
|
|
106
|
+
if properties:
|
|
107
|
+
base_payload["properties"] = properties
|
|
108
|
+
|
|
109
|
+
# Handle sorting
|
|
110
|
+
if order_by:
|
|
111
|
+
direction = str(order).lower() if order else "asc"
|
|
112
|
+
base_payload["sorts"] = [f"{order_by} {direction}"]
|
|
113
|
+
|
|
114
|
+
accumulated_results = []
|
|
115
|
+
count_skipped = 0
|
|
116
|
+
count_collected = 0
|
|
117
|
+
after = None
|
|
118
|
+
|
|
119
|
+
async with aiohttp.ClientSession() as session:
|
|
120
|
+
while True:
|
|
121
|
+
needed = limit - count_collected
|
|
122
|
+
if needed <= 0:
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
# HubSpot typically limits page size to 100
|
|
126
|
+
# But we also must accommodate offset skipping.
|
|
127
|
+
page_limit = min(100, needed + offset - count_skipped)
|
|
128
|
+
if page_limit <= 0:
|
|
129
|
+
# offset is too large for the available data
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
payload = dict(base_payload)
|
|
133
|
+
payload["limit"] = page_limit
|
|
134
|
+
if after:
|
|
135
|
+
payload["after"] = after
|
|
136
|
+
|
|
137
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
138
|
+
result = await response.json()
|
|
139
|
+
if response.status != 200:
|
|
140
|
+
return {'error': result}
|
|
141
|
+
|
|
142
|
+
results_list = result.get('results', [])
|
|
143
|
+
paging_info = result.get('paging', {})
|
|
144
|
+
after = paging_info.get('next', {}).get('after')
|
|
145
|
+
|
|
146
|
+
# Emulate offset-based skipping
|
|
147
|
+
for record in results_list:
|
|
148
|
+
if count_skipped < offset:
|
|
149
|
+
count_skipped += 1
|
|
150
|
+
else:
|
|
151
|
+
accumulated_results.append(record)
|
|
152
|
+
count_collected += 1
|
|
153
|
+
|
|
154
|
+
if count_collected >= limit:
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
if not after or count_collected >= limit:
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"total": len(accumulated_results),
|
|
162
|
+
"results": accumulated_results
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --------------------------------------------------------------------
|
|
167
|
+
# 3. Fetch Companies in CRM (Pagination)
|
|
168
|
+
# with offset, limit, order_by, order
|
|
169
|
+
# --------------------------------------------------------------------
|
|
170
|
+
@assistant_tool
|
|
171
|
+
async def fetch_companies_in_crm(
|
|
172
|
+
list_name: Optional[str] = None,
|
|
173
|
+
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
|
174
|
+
limit: int = Query(10, gt=0, le=2000, description="Max number of records to return"),
|
|
175
|
+
order_by: Optional[str] = Query(None, description="Field to order by"),
|
|
176
|
+
order: Optional[str] = Query("asc", description="Sort order (asc or desc)"),
|
|
177
|
+
tool_config: Optional[List[Dict]] = None
|
|
178
|
+
) -> List[Dict]:
|
|
179
|
+
"""
|
|
180
|
+
Fetch companies in HubSpot CRM, optionally from a specific list.
|
|
181
|
+
Now supports offset, limit, order_by, and order using HubSpot V3 endpoints.
|
|
182
|
+
"""
|
|
183
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
184
|
+
headers = {
|
|
185
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
186
|
+
"Content-Type": "application/json"
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# For V3 companies endpoint, we can pass "sort=-createdate" for descending, etc.
|
|
190
|
+
sort_param = None
|
|
191
|
+
if order_by:
|
|
192
|
+
# If order == "desc", prefix with "-"
|
|
193
|
+
direction_prefix = "-" if order.lower() == "desc" else ""
|
|
194
|
+
sort_param = f"{direction_prefix}{order_by}"
|
|
195
|
+
|
|
196
|
+
accumulated_companies = []
|
|
197
|
+
count_skipped = 0
|
|
198
|
+
count_collected = 0
|
|
199
|
+
after = None
|
|
200
|
+
|
|
201
|
+
async with aiohttp.ClientSession() as session:
|
|
202
|
+
if list_name:
|
|
203
|
+
# If fetching companies from a named list
|
|
204
|
+
list_info = await fetch_hubspot_list_by_name(list_name, 'companies', tool_config)
|
|
205
|
+
if list_info is None or "list" not in list_info:
|
|
206
|
+
raise Exception(f"List '{list_name}' not found.")
|
|
207
|
+
list_id = list_info["list"]["listId"]
|
|
208
|
+
|
|
209
|
+
memberships_url = f"https://api.hubapi.com/crm/v3/lists/{list_id}/memberships"
|
|
210
|
+
|
|
211
|
+
while True:
|
|
212
|
+
if count_collected >= limit:
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
fetch_amount = min(100, (limit + offset) - (count_skipped + count_collected))
|
|
216
|
+
if fetch_amount <= 0:
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
params = {"limit": fetch_amount}
|
|
220
|
+
if after:
|
|
221
|
+
params["after"] = after
|
|
222
|
+
|
|
223
|
+
async with session.get(memberships_url, headers=headers, params=params) as response:
|
|
224
|
+
if response.status != 200:
|
|
225
|
+
error_details = await response.text()
|
|
226
|
+
raise Exception(f"Error: Received status code {response.status} with details: {error_details}")
|
|
227
|
+
result = await response.json()
|
|
228
|
+
|
|
229
|
+
memberships = result.get('results', [])
|
|
230
|
+
after = result.get('paging', {}).get('next', {}).get('after')
|
|
231
|
+
record_ids = [member['recordId'] for member in memberships]
|
|
232
|
+
|
|
233
|
+
if record_ids:
|
|
234
|
+
batch_url = "https://api.hubapi.com/crm/v3/objects/companies/batch/read"
|
|
235
|
+
batch_data = {
|
|
236
|
+
"properties": [
|
|
237
|
+
"name", "domain", "annualrevenue", "numberofemployees",
|
|
238
|
+
"description", "linkedin_company_page", "city", "state", "zip"
|
|
239
|
+
],
|
|
240
|
+
"inputs": [{"id": rid} for rid in record_ids]
|
|
241
|
+
}
|
|
242
|
+
async with session.post(batch_url, headers=headers, json=batch_data) as company_response:
|
|
243
|
+
if company_response.status != 200:
|
|
244
|
+
error_details = await company_response.text()
|
|
245
|
+
raise Exception(f"Error fetching company details: {company_response.status} "
|
|
246
|
+
f"with details: {error_details}")
|
|
247
|
+
company_result = await company_response.json()
|
|
248
|
+
new_companies = company_result.get('results', [])
|
|
249
|
+
|
|
250
|
+
for comp in new_companies:
|
|
251
|
+
if count_skipped < offset:
|
|
252
|
+
count_skipped += 1
|
|
253
|
+
else:
|
|
254
|
+
accumulated_companies.append(comp)
|
|
255
|
+
count_collected += 1
|
|
256
|
+
if count_collected >= limit:
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
if not after or count_collected >= limit:
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
else:
|
|
263
|
+
# No list_name: fetch from /crm/v3/objects/companies
|
|
264
|
+
base_url = "https://api.hubapi.com/crm/v3/objects/companies"
|
|
265
|
+
|
|
266
|
+
while True:
|
|
267
|
+
if count_collected >= limit:
|
|
268
|
+
break
|
|
269
|
+
|
|
270
|
+
fetch_amount = min(100, (limit + offset) - (count_skipped + count_collected))
|
|
271
|
+
if fetch_amount <= 0:
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
params = {
|
|
275
|
+
"limit": fetch_amount,
|
|
276
|
+
"properties": [
|
|
277
|
+
"name", "domain", "annualrevenue", "numberofemployees",
|
|
278
|
+
"description", "linkedin_company_page", "city", "state", "zip"
|
|
279
|
+
],
|
|
280
|
+
}
|
|
281
|
+
if after:
|
|
282
|
+
params["after"] = after
|
|
283
|
+
if sort_param:
|
|
284
|
+
params["sort"] = sort_param
|
|
285
|
+
|
|
286
|
+
async with session.get(base_url, headers=headers, params=params) as response:
|
|
287
|
+
if response.status != 200:
|
|
288
|
+
error_details = await response.text()
|
|
289
|
+
raise Exception(f"Error: Received status code {response.status} with details: {error_details}")
|
|
290
|
+
result = await response.json()
|
|
291
|
+
|
|
292
|
+
new_companies = result.get('results', [])
|
|
293
|
+
after = result.get('paging', {}).get('next', {}).get('after')
|
|
294
|
+
|
|
295
|
+
for comp in new_companies:
|
|
296
|
+
if count_skipped < offset:
|
|
297
|
+
count_skipped += 1
|
|
298
|
+
else:
|
|
299
|
+
accumulated_companies.append(comp)
|
|
300
|
+
count_collected += 1
|
|
301
|
+
|
|
302
|
+
if count_collected >= limit:
|
|
303
|
+
break
|
|
304
|
+
|
|
305
|
+
if not after or count_collected >= limit:
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
return accumulated_companies
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def transform_hubspot_contact_to_lead_info(
|
|
312
|
+
hubspot_contact_properties: Dict[str, Any]
|
|
313
|
+
) -> HubSpotLeadInformation:
|
|
314
|
+
"""
|
|
315
|
+
Convert a raw HubSpot property dict into a HubSpotLeadInformation object.
|
|
316
|
+
- Maps standard fields from HUBSPOT_TO_LEAD_MAPPING.
|
|
317
|
+
- Detects LinkedIn URLs.
|
|
318
|
+
- Cleans up empty fields in additional_properties; Pydantic expects a dict.
|
|
319
|
+
"""
|
|
320
|
+
result = {
|
|
321
|
+
"full_name": "",
|
|
322
|
+
"first_name": "",
|
|
323
|
+
"last_name": "",
|
|
324
|
+
"email": "",
|
|
325
|
+
"user_linkedin_url": "",
|
|
326
|
+
"primary_domain_of_organization": "",
|
|
327
|
+
"job_title": "",
|
|
328
|
+
"phone": "",
|
|
329
|
+
"headline": "",
|
|
330
|
+
"lead_location": "",
|
|
331
|
+
"organization_name": "",
|
|
332
|
+
"organization_website": "",
|
|
333
|
+
"organization_linkedin_url": "",
|
|
334
|
+
}
|
|
335
|
+
result["additional_properties"] = {}
|
|
336
|
+
|
|
337
|
+
# 1) Map standard HubSpot properties to lead fields
|
|
338
|
+
for hubspot_prop, raw_value in hubspot_contact_properties.items():
|
|
339
|
+
if hubspot_prop in HUBSPOT_TO_LEAD_MAPPING:
|
|
340
|
+
mapped_field = HUBSPOT_TO_LEAD_MAPPING[hubspot_prop]
|
|
341
|
+
result[mapped_field] = str(raw_value) if raw_value else ""
|
|
342
|
+
|
|
343
|
+
# 2) Detect LinkedIn user/company URLs
|
|
344
|
+
value_str = str(raw_value)
|
|
345
|
+
if "linkedin.com/in/" in value_str and is_valid_url(value_str):
|
|
346
|
+
result["user_linkedin_url"] = value_str
|
|
347
|
+
if "linkedin.com/company/" in value_str and is_valid_url(value_str):
|
|
348
|
+
result["organization_linkedin_url"] = value_str
|
|
349
|
+
|
|
350
|
+
# 3) Build "full_name" if missing
|
|
351
|
+
if not result["full_name"]:
|
|
352
|
+
fn = result["first_name"].strip()
|
|
353
|
+
ln = result["last_name"].strip()
|
|
354
|
+
result["full_name"] = (fn + " " + ln).strip()
|
|
355
|
+
|
|
356
|
+
# 4) Copy any unmapped fields into additional_properties
|
|
357
|
+
additional_info = {}
|
|
358
|
+
standard_mapped_keys = set(HUBSPOT_TO_LEAD_MAPPING.keys()) | {
|
|
359
|
+
"user_linkedin_url", "organization_linkedin_url"
|
|
360
|
+
}
|
|
361
|
+
for k, v in hubspot_contact_properties.items():
|
|
362
|
+
if k not in standard_mapped_keys and v is not None and str(v).strip():
|
|
363
|
+
additional_info[k] = str(v).strip()
|
|
364
|
+
|
|
365
|
+
# 5) Clean up empties in the additional info
|
|
366
|
+
cleaned_dict = cleanup_properties(additional_info)
|
|
367
|
+
|
|
368
|
+
# Do NOT serialize; assign the dictionary directly
|
|
369
|
+
result["additional_properties"]["hubspot_lead_information"] = json.dumps(cleaned_dict)
|
|
370
|
+
|
|
371
|
+
# 6) Construct the Pydantic model
|
|
372
|
+
return HubSpotLeadInformation(**result)
|
|
373
|
+
|
|
374
|
+
@assistant_tool
|
|
375
|
+
async def fetch_hubspot_list_records(
|
|
376
|
+
list_id: str,
|
|
377
|
+
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
|
378
|
+
limit: int = Query(10, gt=0, le=2000, description="Max number of records to return"),
|
|
379
|
+
order_by: Optional[str] = Query(None, description="Field to order by"),
|
|
380
|
+
order: Optional[str] = Query("asc", description="Sort order (asc or desc)"),
|
|
381
|
+
tool_config: Optional[List[Dict]] = None
|
|
382
|
+
) -> List[HubSpotLeadInformation]:
|
|
383
|
+
"""
|
|
384
|
+
Fetch contact records from a specific HubSpot list using the v3 API,
|
|
385
|
+
then transform each one to a HubSpotLeadInformation.
|
|
386
|
+
"""
|
|
387
|
+
HUBSPOT_ACCESS_TOKEN = get_hubspot_access_token(tool_config)
|
|
388
|
+
if not list_id:
|
|
389
|
+
raise ValueError("HubSpot list ID must be provided")
|
|
390
|
+
|
|
391
|
+
headers = {
|
|
392
|
+
"Authorization": f"Bearer {HUBSPOT_ACCESS_TOKEN}",
|
|
393
|
+
"Content-Type": "application/json"
|
|
394
|
+
}
|
|
395
|
+
membership_url = f"https://api.hubapi.com/crm/v3/lists/{list_id}/memberships"
|
|
396
|
+
accumulated_ids = []
|
|
397
|
+
count_skipped = 0
|
|
398
|
+
count_collected = 0
|
|
399
|
+
after = None
|
|
400
|
+
|
|
401
|
+
async with aiohttp.ClientSession() as session:
|
|
402
|
+
# 1) Page through memberships (contact IDs)
|
|
403
|
+
while True:
|
|
404
|
+
if count_collected >= limit:
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
fetch_amount = min(100, (limit + offset) - (count_skipped + count_collected))
|
|
408
|
+
if fetch_amount <= 0:
|
|
409
|
+
break
|
|
410
|
+
|
|
411
|
+
params = {"limit": fetch_amount}
|
|
412
|
+
if after:
|
|
413
|
+
params["after"] = after
|
|
414
|
+
|
|
415
|
+
async with session.get(membership_url, headers=headers, params=params) as response:
|
|
416
|
+
if response.status != 200:
|
|
417
|
+
error_details = await response.text()
|
|
418
|
+
raise Exception(
|
|
419
|
+
f"Error: Could not fetch list memberships. "
|
|
420
|
+
f"Status code {response.status}. Details: {error_details}"
|
|
421
|
+
)
|
|
422
|
+
memberships_data = await response.json()
|
|
423
|
+
memberships = memberships_data.get('results', [])
|
|
424
|
+
after = memberships_data.get('paging', {}).get('next', {}).get('after')
|
|
425
|
+
|
|
426
|
+
for m in memberships:
|
|
427
|
+
cid = m['recordId']
|
|
428
|
+
if count_skipped < offset:
|
|
429
|
+
count_skipped += 1
|
|
430
|
+
else:
|
|
431
|
+
accumulated_ids.append(cid)
|
|
432
|
+
count_collected += 1
|
|
433
|
+
if count_collected >= limit:
|
|
434
|
+
break
|
|
435
|
+
|
|
436
|
+
if not after or count_collected >= limit:
|
|
437
|
+
break
|
|
438
|
+
|
|
439
|
+
if not accumulated_ids:
|
|
440
|
+
return []
|
|
441
|
+
|
|
442
|
+
# 2) Fetch batch contact details
|
|
443
|
+
batch_read_url = "https://api.hubapi.com/crm/v3/objects/contacts/batch/read"
|
|
444
|
+
all_properties = await _fetch_all_contact_properties(headers)
|
|
445
|
+
|
|
446
|
+
contact_leads = []
|
|
447
|
+
batch_size = 100
|
|
448
|
+
for i in range(0, len(accumulated_ids), batch_size):
|
|
449
|
+
batch_ids = accumulated_ids[i:i + batch_size]
|
|
450
|
+
payload = {
|
|
451
|
+
"properties": all_properties,
|
|
452
|
+
"inputs": [{"id": cid} for cid in batch_ids]
|
|
453
|
+
}
|
|
454
|
+
async with session.post(batch_read_url, headers=headers, json=payload) as r:
|
|
455
|
+
if r.status != 200:
|
|
456
|
+
error_details = await r.text()
|
|
457
|
+
raise Exception(
|
|
458
|
+
f"Error fetching batch contact details. "
|
|
459
|
+
f"Status code {r.status}. Details: {error_details}"
|
|
460
|
+
)
|
|
461
|
+
batch_data = await r.json()
|
|
462
|
+
contact_leads.extend(batch_data.get('results', []))
|
|
463
|
+
|
|
464
|
+
# 3) Local sorting if requested
|
|
465
|
+
if order_by:
|
|
466
|
+
reverse_sort = (order.lower() == "desc")
|
|
467
|
+
contact_leads.sort(
|
|
468
|
+
key=lambda c: c.get("properties", {}).get(order_by, ""),
|
|
469
|
+
reverse=reverse_sort
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# 4) Transform each contact to HubSpotLeadInformation
|
|
473
|
+
final_leads: List[HubSpotLeadInformation] = []
|
|
474
|
+
for c in contact_leads:
|
|
475
|
+
# Combine top-level ID if you also want to store it
|
|
476
|
+
properties = c.get("properties", {})
|
|
477
|
+
# properties["id"] = c.get("id", "") # optionally store the contact ID in the properties
|
|
478
|
+
lead_info = transform_hubspot_contact_to_lead_info(properties)
|
|
479
|
+
final_leads.append(lead_info)
|
|
480
|
+
|
|
481
|
+
return final_leads
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
async def _fetch_all_contact_properties(headers: Dict[str, str]) -> List[str]:
|
|
485
|
+
"""
|
|
486
|
+
Helper to fetch all contact property names from HubSpot via the V3 properties API.
|
|
487
|
+
"""
|
|
488
|
+
properties_url = "https://api.hubapi.com/crm/v3/properties/contacts"
|
|
489
|
+
async with aiohttp.ClientSession() as session:
|
|
490
|
+
async with session.get(properties_url, headers=headers) as prop_resp:
|
|
491
|
+
if prop_resp.status != 200:
|
|
492
|
+
error_details = await prop_resp.text()
|
|
493
|
+
raise Exception(
|
|
494
|
+
f"Error fetching contact properties. "
|
|
495
|
+
f"Status {prop_resp.status}. Details: {error_details}"
|
|
496
|
+
)
|
|
497
|
+
prop_data = await prop_resp.json()
|
|
498
|
+
return [p["name"] for p in prop_data.get("results", [])]
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@assistant_tool
|
|
503
|
+
async def list_all_crm_lists(
|
|
504
|
+
payload: Optional[Dict] = None,
|
|
505
|
+
list_type: str = "contacts",
|
|
506
|
+
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
|
507
|
+
limit: int = Query(10, gt=0, le=2000, description="Max number of records to return"),
|
|
508
|
+
order_by: Optional[str] = Query(None, description="Field to order by"),
|
|
509
|
+
order: Optional[str] = Query("asc", description="Sort order (asc or desc)"),
|
|
510
|
+
tool_config: Optional[List[Dict]] = None
|
|
511
|
+
):
|
|
512
|
+
"""
|
|
513
|
+
Fetches CRM lists from HubSpot with optional offset, limit, order_by, and order.
|
|
514
|
+
Sorting is now handled server-side by including the sort criteria in the payload.
|
|
515
|
+
Defaults to searching for contact lists.
|
|
516
|
+
"""
|
|
517
|
+
object_type_map = {
|
|
518
|
+
"contacts": "0-1",
|
|
519
|
+
"companies": "0-2",
|
|
520
|
+
"deals": "0-3"
|
|
521
|
+
}
|
|
522
|
+
order_by = str(order_by or "name")
|
|
523
|
+
order = str(order or "asc").lower()
|
|
524
|
+
|
|
525
|
+
HUBSPOT_ACCESS_TOKEN = get_hubspot_access_token(tool_config)
|
|
526
|
+
|
|
527
|
+
# Build the base payload for the "crm/v3/lists/search" endpoint
|
|
528
|
+
if payload is None:
|
|
529
|
+
payload = {
|
|
530
|
+
"listIds": [],
|
|
531
|
+
"offset": offset, # Use the provided offset
|
|
532
|
+
"query": "",
|
|
533
|
+
"count": limit, # Use limit as the count parameter for HubSpot
|
|
534
|
+
"processingTypes": [],
|
|
535
|
+
"additionalProperties": []
|
|
536
|
+
}
|
|
537
|
+
payload["offset"] = offset
|
|
538
|
+
payload["count"] = limit
|
|
539
|
+
|
|
540
|
+
# Include sorting parameters if order_by is provided.
|
|
541
|
+
# HubSpot expects a "sorts" array with propertyName and direction ("ASCENDING" or "DESCENDING").
|
|
542
|
+
if order_by:
|
|
543
|
+
payload["sorts"] = [{
|
|
544
|
+
"propertyName": order_by,
|
|
545
|
+
"direction": "DESCENDING" if order.lower() == "desc" else "ASCENDING"
|
|
546
|
+
}]
|
|
547
|
+
else:
|
|
548
|
+
# Optionally, set a default sort if none is provided.
|
|
549
|
+
payload["sorts"] = [{
|
|
550
|
+
"propertyName": "name",
|
|
551
|
+
"direction": "ASCENDING"
|
|
552
|
+
}]
|
|
553
|
+
|
|
554
|
+
object_id = object_type_map.get(list_type, "0-1")
|
|
555
|
+
url = "https://api.hubapi.com/crm/v3/lists/search"
|
|
556
|
+
headers = {
|
|
557
|
+
"Authorization": f"Bearer {HUBSPOT_ACCESS_TOKEN}",
|
|
558
|
+
"Content-Type": "application/json"
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
all_results = []
|
|
562
|
+
count_skipped = 0
|
|
563
|
+
count_collected = 0
|
|
564
|
+
|
|
565
|
+
async with aiohttp.ClientSession() as session:
|
|
566
|
+
while True:
|
|
567
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
568
|
+
if response.status != 200:
|
|
569
|
+
error_details = await response.text()
|
|
570
|
+
raise Exception(
|
|
571
|
+
f"Error: Received status code {response.status} with details: {error_details}"
|
|
572
|
+
)
|
|
573
|
+
data = await response.json()
|
|
574
|
+
page_lists = data.get("lists", [])
|
|
575
|
+
has_more = data.get("hasMore", False)
|
|
576
|
+
next_offset = data.get("offset", None)
|
|
577
|
+
|
|
578
|
+
# Filter results by objectTypeId
|
|
579
|
+
filtered_lists = [lst for lst in page_lists if lst.get("objectTypeId") == object_id]
|
|
580
|
+
|
|
581
|
+
for lst_obj in filtered_lists:
|
|
582
|
+
if count_skipped < offset:
|
|
583
|
+
count_skipped += 1
|
|
584
|
+
else:
|
|
585
|
+
all_results.append(lst_obj)
|
|
586
|
+
count_collected += 1
|
|
587
|
+
if count_collected >= limit:
|
|
588
|
+
break
|
|
589
|
+
|
|
590
|
+
if not has_more or count_collected >= limit or next_offset is None:
|
|
591
|
+
break
|
|
592
|
+
|
|
593
|
+
# Update offset for next page
|
|
594
|
+
payload["offset"] = next_offset
|
|
595
|
+
|
|
596
|
+
return all_results
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# --------------------------------------------------------------------
|
|
601
|
+
# 6. fetch_hubspot_list_by_name (Helper)
|
|
602
|
+
# --------------------------------------------------------------------
|
|
603
|
+
async def fetch_hubspot_list_by_name(
|
|
604
|
+
list_name: str,
|
|
605
|
+
list_type: str = 'contacts',
|
|
606
|
+
tool_config: Optional[List[Dict]] = None
|
|
607
|
+
):
|
|
608
|
+
"""
|
|
609
|
+
Fetch information for a specific HubSpot list using the list's name.
|
|
610
|
+
"""
|
|
611
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
612
|
+
if not list_name:
|
|
613
|
+
raise ValueError("HubSpot list name must be provided")
|
|
614
|
+
|
|
615
|
+
object_type_ids = {
|
|
616
|
+
'contacts': '0-1',
|
|
617
|
+
'companies': '0-2',
|
|
618
|
+
'deals': '0-3',
|
|
619
|
+
'tickets': '0-5',
|
|
620
|
+
}
|
|
621
|
+
object_type_id = object_type_ids.get(list_type.lower())
|
|
622
|
+
if not object_type_id:
|
|
623
|
+
raise ValueError(f"Invalid list type '{list_type}'. Valid types are: {list(object_type_ids.keys())}")
|
|
624
|
+
|
|
625
|
+
url = f"https://api.hubapi.com/crm/v3/lists/object-type-id/{object_type_id}/name/{list_name}"
|
|
626
|
+
headers = {
|
|
627
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
628
|
+
"Content-Type": "application/json"
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async with aiohttp.ClientSession() as session:
|
|
632
|
+
async with session.get(url, headers=headers) as response:
|
|
633
|
+
if response.status == 200:
|
|
634
|
+
list_info = await response.json()
|
|
635
|
+
return list_info
|
|
636
|
+
elif response.status == 404:
|
|
637
|
+
raise Exception(f"List with name '{list_name}' not found for object type '{list_type}'")
|
|
638
|
+
else:
|
|
639
|
+
error_details = await response.text()
|
|
640
|
+
raise Exception(f"Error: Received status code {response.status} with details: {error_details}")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# --------------------------------------------------------------------
|
|
644
|
+
# 7. Single Object / Non-paginated Tools (Unchanged)
|
|
645
|
+
# --------------------------------------------------------------------
|
|
646
|
+
@assistant_tool
|
|
647
|
+
async def fetch_hubspot_object_info(
|
|
648
|
+
object_type: str,
|
|
649
|
+
object_id: Optional[str] = None,
|
|
650
|
+
object_ids: Optional[List[str]] = None,
|
|
651
|
+
associations: Optional[List[str]] = None,
|
|
652
|
+
properties: Optional[List[str]] = None,
|
|
653
|
+
tool_config: Optional[List[Dict]] = None
|
|
654
|
+
):
|
|
655
|
+
"""
|
|
656
|
+
Fetch information for any HubSpot object(s) (contacts, companies, deals, tickets, lists, etc.)
|
|
657
|
+
"""
|
|
658
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
659
|
+
if not object_id and not object_ids:
|
|
660
|
+
return {'error': "HubSpot object ID(s) must be provided"}
|
|
661
|
+
if not object_type:
|
|
662
|
+
return {'error': "HubSpot object type must be provided"}
|
|
663
|
+
|
|
664
|
+
headers = {
|
|
665
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
666
|
+
"Content-Type": "application/json"
|
|
667
|
+
}
|
|
668
|
+
params = {}
|
|
669
|
+
if properties:
|
|
670
|
+
params['properties'] = ','.join(properties)
|
|
671
|
+
if associations:
|
|
672
|
+
params['associations'] = ','.join(associations)
|
|
673
|
+
|
|
674
|
+
try:
|
|
675
|
+
async with aiohttp.ClientSession() as session:
|
|
676
|
+
if object_type.lower() == 'lists':
|
|
677
|
+
# Handle lists endpoint
|
|
678
|
+
if object_id:
|
|
679
|
+
url = f"https://api.hubapi.com/contacts/v1/lists/{object_id}"
|
|
680
|
+
async with session.get(url, headers=headers, params=params) as response:
|
|
681
|
+
result = await response.json()
|
|
682
|
+
if response.status != 200:
|
|
683
|
+
return {'error': result}
|
|
684
|
+
return result
|
|
685
|
+
else:
|
|
686
|
+
return {'error': "For object_type 'lists', object_id must be provided"}
|
|
687
|
+
else:
|
|
688
|
+
if object_ids:
|
|
689
|
+
# Batch read
|
|
690
|
+
url = f"https://api.hubapi.com/crm/v3/objects/{object_type}/batch/read"
|
|
691
|
+
payload = {
|
|
692
|
+
"inputs": [{"id": oid} for oid in object_ids]
|
|
693
|
+
}
|
|
694
|
+
if properties:
|
|
695
|
+
payload["properties"] = properties
|
|
696
|
+
if associations:
|
|
697
|
+
payload["associations"] = associations
|
|
698
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
699
|
+
result = await response.json()
|
|
700
|
+
if response.status != 200:
|
|
701
|
+
return {'error': result}
|
|
702
|
+
return result
|
|
703
|
+
else:
|
|
704
|
+
# Single object read
|
|
705
|
+
url = f"https://api.hubapi.com/crm/v3/objects/{object_type}/{object_id}"
|
|
706
|
+
async with session.get(url, headers=headers, params=params) as response:
|
|
707
|
+
result = await response.json()
|
|
708
|
+
if response.status != 200:
|
|
709
|
+
return {'error': result}
|
|
710
|
+
return result
|
|
711
|
+
except Exception as e:
|
|
712
|
+
return {'error': str(e)}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def is_valid_url(url: str) -> bool:
|
|
716
|
+
"""
|
|
717
|
+
Check if the given string is a well-formed http/https URL.
|
|
718
|
+
"""
|
|
719
|
+
parsed = urlparse(url)
|
|
720
|
+
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@assistant_tool
|
|
724
|
+
async def update_hubspot_contact_properties(
|
|
725
|
+
contact_id: str,
|
|
726
|
+
properties: dict,
|
|
727
|
+
tool_config: Optional[List[Dict]] = None
|
|
728
|
+
):
|
|
729
|
+
"""
|
|
730
|
+
Update contact properties in HubSpot for a given contact ID.
|
|
731
|
+
[Unchanged single-object logic...]
|
|
732
|
+
"""
|
|
733
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
734
|
+
if not contact_id:
|
|
735
|
+
raise ValueError("HubSpot contact ID must be provided")
|
|
736
|
+
|
|
737
|
+
if not properties:
|
|
738
|
+
raise ValueError("Properties dictionary must be provided")
|
|
739
|
+
|
|
740
|
+
url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}"
|
|
741
|
+
headers = {
|
|
742
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
743
|
+
"Content-Type": "application/json"
|
|
744
|
+
}
|
|
745
|
+
payload = { "properties": properties }
|
|
746
|
+
|
|
747
|
+
async with aiohttp.ClientSession() as session:
|
|
748
|
+
async with session.patch(url, headers=headers, json=payload) as response:
|
|
749
|
+
if response.status != 200:
|
|
750
|
+
raise Exception(f"Error: Received status code {response.status}")
|
|
751
|
+
result = await response.json()
|
|
752
|
+
return result
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# --------------------------------------------------------------------
|
|
756
|
+
# 1) Update HubSpot "lead" properties (via V3)
|
|
757
|
+
# (Assuming your "lead" is a custom object named "leads" in HubSpot)
|
|
758
|
+
# --------------------------------------------------------------------
|
|
759
|
+
@assistant_tool
|
|
760
|
+
async def update_hubspot_lead_properties(
|
|
761
|
+
lead_id: str,
|
|
762
|
+
properties: dict,
|
|
763
|
+
tool_config: Optional[List[Dict]] = None
|
|
764
|
+
):
|
|
765
|
+
"""
|
|
766
|
+
Update lead (custom object) properties in HubSpot for a given lead ID (v3).
|
|
767
|
+
"""
|
|
768
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
769
|
+
|
|
770
|
+
if not lead_id:
|
|
771
|
+
raise ValueError("HubSpot lead ID must be provided")
|
|
772
|
+
if not properties:
|
|
773
|
+
raise ValueError("Properties dictionary must be provided")
|
|
774
|
+
|
|
775
|
+
url = f"https://api.hubapi.com/crm/v3/objects/leads/{lead_id}"
|
|
776
|
+
headers = {
|
|
777
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
778
|
+
"Content-Type": "application/json"
|
|
779
|
+
}
|
|
780
|
+
payload = {"properties": properties}
|
|
781
|
+
|
|
782
|
+
async with aiohttp.ClientSession() as session:
|
|
783
|
+
async with session.patch(url, headers=headers, json=payload) as response:
|
|
784
|
+
result = await response.json()
|
|
785
|
+
if response.status != 200:
|
|
786
|
+
raise Exception(f"Error updating lead: {response.status} => {result}")
|
|
787
|
+
return result
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# --------------------------------------------------------------------
|
|
791
|
+
# 2) Fetch HubSpot Company Info (via V3)
|
|
792
|
+
# - If company_id is given, do a direct GET.
|
|
793
|
+
# - Else if name/domain is provided, do a search.
|
|
794
|
+
# --------------------------------------------------------------------
|
|
795
|
+
@assistant_tool
|
|
796
|
+
async def fetch_hubspot_company_info(
|
|
797
|
+
company_id: str = None,
|
|
798
|
+
name: str = None,
|
|
799
|
+
domain: str = None,
|
|
800
|
+
tool_config: Optional[List[Dict]] = None
|
|
801
|
+
):
|
|
802
|
+
"""
|
|
803
|
+
Fetch company information from HubSpot using the company's HubSpot ID,
|
|
804
|
+
or by searching via 'name' or 'domain'. Returns the first match if searching.
|
|
805
|
+
"""
|
|
806
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
807
|
+
|
|
808
|
+
if not any([company_id, name, domain]):
|
|
809
|
+
raise ValueError("At least one of 'company_id', 'name', or 'domain' must be provided.")
|
|
810
|
+
|
|
811
|
+
headers = {
|
|
812
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
813
|
+
"Content-Type": "application/json"
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async with aiohttp.ClientSession() as session:
|
|
817
|
+
# ---------------------------------------------------------
|
|
818
|
+
# Case 1: If we have a company_id, do a direct GET
|
|
819
|
+
# ---------------------------------------------------------
|
|
820
|
+
if company_id:
|
|
821
|
+
url = f"https://api.hubapi.com/crm/v3/objects/companies/{company_id}"
|
|
822
|
+
params = {"properties": "name,domain,industry,city,state,linkedin_company_page,numberofemployees,annualrevenue"}
|
|
823
|
+
async with session.get(url, headers=headers, params=params) as resp:
|
|
824
|
+
company_info = await resp.json()
|
|
825
|
+
if resp.status != 200:
|
|
826
|
+
raise Exception(f"Error fetching company by ID: {resp.status} => {company_info}")
|
|
827
|
+
return company_info
|
|
828
|
+
|
|
829
|
+
# ---------------------------------------------------------
|
|
830
|
+
# Case 2: Otherwise, do a search by name/domain
|
|
831
|
+
# ---------------------------------------------------------
|
|
832
|
+
search_url = "https://api.hubapi.com/crm/v3/objects/companies/search"
|
|
833
|
+
filters = []
|
|
834
|
+
if name:
|
|
835
|
+
filters.append({"propertyName": "name", "operator": "EQ", "value": name})
|
|
836
|
+
if domain:
|
|
837
|
+
filters.append({"propertyName": "domain", "operator": "EQ", "value": domain})
|
|
838
|
+
|
|
839
|
+
payload = {
|
|
840
|
+
"filterGroups": [
|
|
841
|
+
{
|
|
842
|
+
"filters": filters
|
|
843
|
+
}
|
|
844
|
+
],
|
|
845
|
+
"properties": ["name","domain","industry","city","state","linkedin_company_page","numberofemployees","annualrevenue"],
|
|
846
|
+
"limit": 1
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async with session.post(search_url, headers=headers, json=payload) as resp:
|
|
850
|
+
data = await resp.json()
|
|
851
|
+
if resp.status != 200:
|
|
852
|
+
raise Exception(f"Error searching company: {resp.status} => {data}")
|
|
853
|
+
|
|
854
|
+
results = data.get("results", [])
|
|
855
|
+
if not results:
|
|
856
|
+
raise Exception("No matching company found for the given search criteria.")
|
|
857
|
+
|
|
858
|
+
# Return first match
|
|
859
|
+
return results[0]
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
# --------------------------------------------------------------------
|
|
863
|
+
# 3) Update HubSpot Company Info (via V3)
|
|
864
|
+
# - If company_id is provided, update that record
|
|
865
|
+
# - Else if domain is provided, first search by domain to find the ID
|
|
866
|
+
# --------------------------------------------------------------------
|
|
867
|
+
@assistant_tool
|
|
868
|
+
async def update_hubspot_company_info(
|
|
869
|
+
company_id: str = None,
|
|
870
|
+
domain: str = None,
|
|
871
|
+
city: str = None,
|
|
872
|
+
state: str = None,
|
|
873
|
+
number_of_employees: int = None,
|
|
874
|
+
description: str = None,
|
|
875
|
+
linkedin_company_page: str = None,
|
|
876
|
+
annual_revenue: float = None,
|
|
877
|
+
industry: str = None,
|
|
878
|
+
tool_config: Optional[List[Dict]] = None
|
|
879
|
+
):
|
|
880
|
+
"""
|
|
881
|
+
Update company information in HubSpot using the company's HubSpot ID or domain.
|
|
882
|
+
"""
|
|
883
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
884
|
+
|
|
885
|
+
if not company_id and not domain:
|
|
886
|
+
raise ValueError("Either 'company_id' or 'domain' must be provided.")
|
|
887
|
+
|
|
888
|
+
headers = {
|
|
889
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
890
|
+
"Content-Type": "application/json"
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async with aiohttp.ClientSession() as session:
|
|
894
|
+
# ---------------------------------------------------------
|
|
895
|
+
# If no company_id but domain is provided -> find company_id first
|
|
896
|
+
# ---------------------------------------------------------
|
|
897
|
+
if not company_id and domain:
|
|
898
|
+
search_url = "https://api.hubapi.com/crm/v3/objects/companies/search"
|
|
899
|
+
search_payload = {
|
|
900
|
+
"filterGroups": [
|
|
901
|
+
{
|
|
902
|
+
"filters": [
|
|
903
|
+
{"propertyName": "domain", "operator": "EQ", "value": domain}
|
|
904
|
+
]
|
|
905
|
+
}
|
|
906
|
+
],
|
|
907
|
+
"limit": 1
|
|
908
|
+
}
|
|
909
|
+
async with session.post(search_url, headers=headers, json=search_payload) as resp:
|
|
910
|
+
data = await resp.json()
|
|
911
|
+
if resp.status != 200:
|
|
912
|
+
raise Exception(f"Error searching company by domain: {resp.status} => {data}")
|
|
913
|
+
|
|
914
|
+
results = data.get("results", [])
|
|
915
|
+
if not results:
|
|
916
|
+
raise Exception(f"No company found with the provided domain '{domain}'.")
|
|
917
|
+
company_id = results[0]["id"]
|
|
918
|
+
|
|
919
|
+
# ---------------------------------------------------------
|
|
920
|
+
# Build properties to update
|
|
921
|
+
# ---------------------------------------------------------
|
|
922
|
+
update_payload = {"properties": {}}
|
|
923
|
+
if city is not None:
|
|
924
|
+
update_payload["properties"]["city"] = city
|
|
925
|
+
if state is not None:
|
|
926
|
+
update_payload["properties"]["state"] = state
|
|
927
|
+
if number_of_employees is not None:
|
|
928
|
+
update_payload["properties"]["numberofemployees"] = number_of_employees
|
|
929
|
+
if description is not None:
|
|
930
|
+
update_payload["properties"]["description"] = description
|
|
931
|
+
if linkedin_company_page is not None:
|
|
932
|
+
update_payload["properties"]["linkedin_company_page"] = linkedin_company_page
|
|
933
|
+
if annual_revenue is not None:
|
|
934
|
+
update_payload["properties"]["annualrevenue"] = annual_revenue
|
|
935
|
+
if industry is not None:
|
|
936
|
+
update_payload["properties"]["industry"] = industry
|
|
937
|
+
|
|
938
|
+
# ---------------------------------------------------------
|
|
939
|
+
# PATCH to update the company
|
|
940
|
+
# ---------------------------------------------------------
|
|
941
|
+
patch_url = f"https://api.hubapi.com/crm/v3/objects/companies/{company_id}"
|
|
942
|
+
async with session.patch(patch_url, headers=headers, json=update_payload) as resp:
|
|
943
|
+
updated_data = await resp.json()
|
|
944
|
+
if resp.status != 200:
|
|
945
|
+
raise Exception(f"Error updating company: {resp.status} => {updated_data}")
|
|
946
|
+
|
|
947
|
+
return updated_data
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
# --------------------------------------------------------------------
|
|
951
|
+
# 5) Get Last N Notes for a Customer (via V3)
|
|
952
|
+
# - If customer_id is None but email is provided, find contact ID
|
|
953
|
+
# - Then list associated notes from /contacts/{id}/associations/notes
|
|
954
|
+
# - Sort by created date descending, return top n
|
|
955
|
+
# --------------------------------------------------------------------
|
|
956
|
+
@assistant_tool
|
|
957
|
+
async def get_last_n_notes_for_customer(
|
|
958
|
+
customer_id: str = None,
|
|
959
|
+
email: str = None,
|
|
960
|
+
n: int = 5,
|
|
961
|
+
tool_config: Optional[List[Dict]] = None
|
|
962
|
+
):
|
|
963
|
+
"""
|
|
964
|
+
Retrieve the last n notes attached to a customer (contact) in HubSpot using the customer's ID or email.
|
|
965
|
+
"""
|
|
966
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
967
|
+
if not (customer_id or email):
|
|
968
|
+
raise ValueError("Either 'customer_id' or 'email' must be provided.")
|
|
969
|
+
|
|
970
|
+
headers = {
|
|
971
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
972
|
+
"Content-Type": "application/json"
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
# -------------------------------------------------------------
|
|
976
|
+
# 1) If no contact ID, lookup by email
|
|
977
|
+
# -------------------------------------------------------------
|
|
978
|
+
async with aiohttp.ClientSession() as session:
|
|
979
|
+
if not customer_id:
|
|
980
|
+
search_url = "https://api.hubapi.com/crm/v3/objects/contacts/search"
|
|
981
|
+
search_payload = {
|
|
982
|
+
"filterGroups": [
|
|
983
|
+
{
|
|
984
|
+
"filters": [
|
|
985
|
+
{"propertyName": "email", "operator": "EQ", "value": email}
|
|
986
|
+
]
|
|
987
|
+
}
|
|
988
|
+
],
|
|
989
|
+
"limit": 1
|
|
990
|
+
}
|
|
991
|
+
async with session.post(search_url, headers=headers, json=search_payload) as resp:
|
|
992
|
+
data = await resp.json()
|
|
993
|
+
if resp.status != 200:
|
|
994
|
+
raise Exception(f"Error searching contact by email: {resp.status} => {data}")
|
|
995
|
+
|
|
996
|
+
results = data.get("results", [])
|
|
997
|
+
if not results:
|
|
998
|
+
raise Exception(f"No contact found with email '{email}'.")
|
|
999
|
+
customer_id = results[0]["id"]
|
|
1000
|
+
|
|
1001
|
+
# -------------------------------------------------------------
|
|
1002
|
+
# 2) Fetch associated notes
|
|
1003
|
+
# GET /crm/v3/objects/contacts/{contact_id}/associations/notes
|
|
1004
|
+
# We'll gather all and then pick the last n by created time.
|
|
1005
|
+
# -------------------------------------------------------------
|
|
1006
|
+
url = f"https://api.hubapi.com/crm/v3/objects/contacts/{customer_id}/associations/notes"
|
|
1007
|
+
notes = []
|
|
1008
|
+
after = None
|
|
1009
|
+
while True:
|
|
1010
|
+
params = {
|
|
1011
|
+
"limit": 100, # fetch up to 100 at a time
|
|
1012
|
+
}
|
|
1013
|
+
if after:
|
|
1014
|
+
params["after"] = after
|
|
1015
|
+
|
|
1016
|
+
async with session.get(url, headers=headers, params=params) as resp:
|
|
1017
|
+
data = await resp.json()
|
|
1018
|
+
if resp.status != 200:
|
|
1019
|
+
raise Exception(f"Error fetching contact->notes associations: {resp.status} => {data}")
|
|
1020
|
+
|
|
1021
|
+
results = data.get("results", [])
|
|
1022
|
+
notes.extend(results)
|
|
1023
|
+
paging = data.get("paging", {})
|
|
1024
|
+
after = paging.get("next", {}).get("after")
|
|
1025
|
+
|
|
1026
|
+
if not after:
|
|
1027
|
+
break
|
|
1028
|
+
|
|
1029
|
+
if not notes:
|
|
1030
|
+
return []
|
|
1031
|
+
|
|
1032
|
+
# We'll fetch each note object fully in batch or individually
|
|
1033
|
+
# For brevity, let's do a batch read:
|
|
1034
|
+
note_ids = [n["id"] for n in notes]
|
|
1035
|
+
|
|
1036
|
+
if not note_ids:
|
|
1037
|
+
return []
|
|
1038
|
+
|
|
1039
|
+
# Build batch read for notes:
|
|
1040
|
+
batch_read_url = "https://api.hubapi.com/crm/v3/objects/notes/batch/read"
|
|
1041
|
+
payload = {
|
|
1042
|
+
"properties": ["hs_note_body","hs_createdate"],
|
|
1043
|
+
"inputs": [{"id": nid} for nid in note_ids]
|
|
1044
|
+
}
|
|
1045
|
+
async with session.post(batch_read_url, headers=headers, json=payload) as resp:
|
|
1046
|
+
batch_data = await resp.json()
|
|
1047
|
+
if resp.status != 200:
|
|
1048
|
+
raise Exception(f"Error batch-reading notes: {resp.status} => {batch_data}")
|
|
1049
|
+
|
|
1050
|
+
full_notes = batch_data.get("results", [])
|
|
1051
|
+
# Sort by created date descending
|
|
1052
|
+
# Typically it's in "properties" -> "hs_createdate"
|
|
1053
|
+
full_notes.sort(
|
|
1054
|
+
key=lambda x: x.get("properties", {}).get("hs_createdate", ""),
|
|
1055
|
+
reverse=True
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
# Return top n
|
|
1059
|
+
return full_notes[:n]
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
# --------------------------------------------------------------------
|
|
1063
|
+
# 5b) Get Last N Call Logs for a Lead (via V3)
|
|
1064
|
+
# - Use lead information to look up the contact by email or by
|
|
1065
|
+
# firstname/lastname and company name
|
|
1066
|
+
# - Then list associated calls from /contacts/{id}/associations/calls
|
|
1067
|
+
# - Sort by created date descending and return the top n
|
|
1068
|
+
# --------------------------------------------------------------------
|
|
1069
|
+
@assistant_tool
|
|
1070
|
+
async def get_last_n_calls_for_lead(
|
|
1071
|
+
lead_info: HubSpotLeadInformation,
|
|
1072
|
+
n: int = 5,
|
|
1073
|
+
tool_config: Optional[List[Dict]] = None,
|
|
1074
|
+
):
|
|
1075
|
+
"""
|
|
1076
|
+
Retrieve the last ``n`` call log records for a contact in HubSpot
|
|
1077
|
+
based on provided ``lead_info`` (email or name & company).
|
|
1078
|
+
"""
|
|
1079
|
+
|
|
1080
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
1081
|
+
if not lead_info:
|
|
1082
|
+
raise ValueError("lead_info must be provided")
|
|
1083
|
+
|
|
1084
|
+
headers = {
|
|
1085
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
1086
|
+
"Content-Type": "application/json",
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async with aiohttp.ClientSession() as session:
|
|
1090
|
+
# -------------------------------------------------------------
|
|
1091
|
+
# 1) Find contact ID via email or name/company
|
|
1092
|
+
# -------------------------------------------------------------
|
|
1093
|
+
search_url = "https://api.hubapi.com/crm/v3/objects/contacts/search"
|
|
1094
|
+
if lead_info.email:
|
|
1095
|
+
search_payload = {
|
|
1096
|
+
"filterGroups": [
|
|
1097
|
+
{
|
|
1098
|
+
"filters": [
|
|
1099
|
+
{
|
|
1100
|
+
"propertyName": "email",
|
|
1101
|
+
"operator": "EQ",
|
|
1102
|
+
"value": lead_info.email,
|
|
1103
|
+
}
|
|
1104
|
+
]
|
|
1105
|
+
}
|
|
1106
|
+
],
|
|
1107
|
+
"limit": 1,
|
|
1108
|
+
}
|
|
1109
|
+
else:
|
|
1110
|
+
filters = []
|
|
1111
|
+
if lead_info.first_name:
|
|
1112
|
+
filters.append(
|
|
1113
|
+
{
|
|
1114
|
+
"propertyName": "firstname",
|
|
1115
|
+
"operator": "EQ",
|
|
1116
|
+
"value": lead_info.first_name,
|
|
1117
|
+
}
|
|
1118
|
+
)
|
|
1119
|
+
if lead_info.last_name:
|
|
1120
|
+
filters.append(
|
|
1121
|
+
{
|
|
1122
|
+
"propertyName": "lastname",
|
|
1123
|
+
"operator": "EQ",
|
|
1124
|
+
"value": lead_info.last_name,
|
|
1125
|
+
}
|
|
1126
|
+
)
|
|
1127
|
+
if lead_info.organization_name:
|
|
1128
|
+
filters.append(
|
|
1129
|
+
{
|
|
1130
|
+
"propertyName": "company",
|
|
1131
|
+
"operator": "EQ",
|
|
1132
|
+
"value": lead_info.organization_name,
|
|
1133
|
+
}
|
|
1134
|
+
)
|
|
1135
|
+
if not filters:
|
|
1136
|
+
raise ValueError(
|
|
1137
|
+
"lead_info must include email or name and company information"
|
|
1138
|
+
)
|
|
1139
|
+
search_payload = {"filterGroups": [{"filters": filters}], "limit": 1}
|
|
1140
|
+
|
|
1141
|
+
async with session.post(
|
|
1142
|
+
search_url, headers=headers, json=search_payload
|
|
1143
|
+
) as resp:
|
|
1144
|
+
data = await resp.json()
|
|
1145
|
+
if resp.status != 200:
|
|
1146
|
+
raise Exception(
|
|
1147
|
+
f"Error searching contact by lead info: {resp.status} => {data}"
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
results = data.get("results", [])
|
|
1151
|
+
if not results:
|
|
1152
|
+
raise Exception("No contact found with the provided lead information")
|
|
1153
|
+
contact_id = results[0]["id"]
|
|
1154
|
+
|
|
1155
|
+
# -------------------------------------------------------------
|
|
1156
|
+
# 2) Fetch associated calls
|
|
1157
|
+
# -------------------------------------------------------------
|
|
1158
|
+
assoc_url = (
|
|
1159
|
+
f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}/associations/calls"
|
|
1160
|
+
)
|
|
1161
|
+
calls = []
|
|
1162
|
+
after = None
|
|
1163
|
+
while True:
|
|
1164
|
+
params = {"limit": 100}
|
|
1165
|
+
if after:
|
|
1166
|
+
params["after"] = after
|
|
1167
|
+
|
|
1168
|
+
async with session.get(assoc_url, headers=headers, params=params) as resp:
|
|
1169
|
+
assoc_data = await resp.json()
|
|
1170
|
+
if resp.status != 200:
|
|
1171
|
+
raise Exception(
|
|
1172
|
+
f"Error fetching contact->calls associations: {resp.status} => {assoc_data}"
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
calls.extend(assoc_data.get("results", []))
|
|
1176
|
+
after = assoc_data.get("paging", {}).get("next", {}).get("after")
|
|
1177
|
+
|
|
1178
|
+
if not after:
|
|
1179
|
+
break
|
|
1180
|
+
|
|
1181
|
+
if not calls:
|
|
1182
|
+
return []
|
|
1183
|
+
|
|
1184
|
+
call_ids = [c["id"] for c in calls]
|
|
1185
|
+
batch_url = "https://api.hubapi.com/crm/v3/objects/calls/batch/read"
|
|
1186
|
+
payload = {
|
|
1187
|
+
"properties": [
|
|
1188
|
+
"hs_call_title",
|
|
1189
|
+
"hs_call_body",
|
|
1190
|
+
"hs_createdate",
|
|
1191
|
+
"hs_call_duration",
|
|
1192
|
+
],
|
|
1193
|
+
"inputs": [{"id": cid} for cid in call_ids],
|
|
1194
|
+
}
|
|
1195
|
+
async with session.post(batch_url, headers=headers, json=payload) as resp:
|
|
1196
|
+
batch_data = await resp.json()
|
|
1197
|
+
if resp.status != 200:
|
|
1198
|
+
raise Exception(
|
|
1199
|
+
f"Error batch-reading calls: {resp.status} => {batch_data}"
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
full_calls = batch_data.get("results", [])
|
|
1203
|
+
full_calls.sort(
|
|
1204
|
+
key=lambda x: x.get("properties", {}).get("hs_createdate", ""),
|
|
1205
|
+
reverse=True,
|
|
1206
|
+
)
|
|
1207
|
+
return full_calls[:n]
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
# --------------------------------------------------------------------
|
|
1211
|
+
# 6) Fetch HubSpot Contact Associations (via V3)
|
|
1212
|
+
# - e.g., fetch a contact's associated companies, deals, tickets, etc.
|
|
1213
|
+
# --------------------------------------------------------------------
|
|
1214
|
+
@assistant_tool
|
|
1215
|
+
async def fetch_hubspot_contact_associations(
|
|
1216
|
+
contact_id: str,
|
|
1217
|
+
to_object_type: str,
|
|
1218
|
+
tool_config: Optional[List[Dict]] = None
|
|
1219
|
+
):
|
|
1220
|
+
"""
|
|
1221
|
+
Fetch associations from a contact to other objects in HubSpot (v3).
|
|
1222
|
+
"""
|
|
1223
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
1224
|
+
if not contact_id:
|
|
1225
|
+
raise ValueError("HubSpot contact ID must be provided.")
|
|
1226
|
+
if not to_object_type:
|
|
1227
|
+
raise ValueError("Target object type must be provided (e.g. 'companies').")
|
|
1228
|
+
|
|
1229
|
+
headers = {
|
|
1230
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
1231
|
+
"Content-Type": "application/json"
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
# Per the V3 docs, the endpoint is:
|
|
1235
|
+
# GET /crm/v3/objects/contacts/{contactId}/associations/{toObjectType}
|
|
1236
|
+
url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}/associations/{to_object_type}"
|
|
1237
|
+
|
|
1238
|
+
all_associations = []
|
|
1239
|
+
after = None
|
|
1240
|
+
|
|
1241
|
+
async with aiohttp.ClientSession() as session:
|
|
1242
|
+
while True:
|
|
1243
|
+
params = {
|
|
1244
|
+
"limit": 100,
|
|
1245
|
+
}
|
|
1246
|
+
if after:
|
|
1247
|
+
params["after"] = after
|
|
1248
|
+
|
|
1249
|
+
async with session.get(url, headers=headers, params=params) as resp:
|
|
1250
|
+
data = await resp.json()
|
|
1251
|
+
if resp.status != 200:
|
|
1252
|
+
raise Exception(f"Error fetching contact associations: {resp.status} => {data}")
|
|
1253
|
+
|
|
1254
|
+
results = data.get("results", [])
|
|
1255
|
+
all_associations.extend(results)
|
|
1256
|
+
paging = data.get("paging", {})
|
|
1257
|
+
after = paging.get("next", {}).get("after")
|
|
1258
|
+
|
|
1259
|
+
if not after:
|
|
1260
|
+
break
|
|
1261
|
+
|
|
1262
|
+
return all_associations
|
|
1263
|
+
|
|
1264
|
+
@assistant_tool
|
|
1265
|
+
async def fetch_hubspot_lead_info(
|
|
1266
|
+
first_name: str = None,
|
|
1267
|
+
last_name: str = None,
|
|
1268
|
+
email: str = None,
|
|
1269
|
+
linkedin_url: str = None,
|
|
1270
|
+
phone_number: str = None,
|
|
1271
|
+
hubspot_id: str = None,
|
|
1272
|
+
tool_config: Optional[List[Dict]] = None
|
|
1273
|
+
):
|
|
1274
|
+
"""
|
|
1275
|
+
Fetch lead information from a custom "leads" object in HubSpot, based on:
|
|
1276
|
+
- hubspot_id (directly)
|
|
1277
|
+
- OR searching by any combination of: first_name, last_name, email, linkedin_url, phone_number
|
|
1278
|
+
Then optionally fetch & merge association info (companies, notes, etc.).
|
|
1279
|
+
"""
|
|
1280
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
1281
|
+
headers = {
|
|
1282
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
1283
|
+
"Content-Type": "application/json"
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
# If hubspot_id is given, do a direct GET:
|
|
1287
|
+
async with aiohttp.ClientSession() as session:
|
|
1288
|
+
if hubspot_id:
|
|
1289
|
+
# -----------------------------------------------------
|
|
1290
|
+
# Direct fetch of the lead by ID
|
|
1291
|
+
# -----------------------------------------------------
|
|
1292
|
+
url = f"https://api.hubapi.com/crm/v3/objects/leads/{hubspot_id}"
|
|
1293
|
+
params = {"properties": "firstname,lastname,email,phone,linkedin_url"}
|
|
1294
|
+
async with session.get(url, headers=headers, params=params) as resp:
|
|
1295
|
+
lead_info = await resp.json()
|
|
1296
|
+
if resp.status != 200:
|
|
1297
|
+
raise Exception(f"Error fetching lead by ID: {resp.status} => {lead_info}")
|
|
1298
|
+
else:
|
|
1299
|
+
# -----------------------------------------------------
|
|
1300
|
+
# Build a search query from non-empty parameters
|
|
1301
|
+
# -----------------------------------------------------
|
|
1302
|
+
filters = []
|
|
1303
|
+
if first_name:
|
|
1304
|
+
filters.append({
|
|
1305
|
+
"propertyName": "firstname", "operator": "EQ", "value": first_name
|
|
1306
|
+
})
|
|
1307
|
+
if last_name:
|
|
1308
|
+
filters.append({
|
|
1309
|
+
"propertyName": "lastname", "operator": "EQ", "value": last_name
|
|
1310
|
+
})
|
|
1311
|
+
if email:
|
|
1312
|
+
filters.append({
|
|
1313
|
+
"propertyName": "email", "operator": "EQ", "value": email
|
|
1314
|
+
})
|
|
1315
|
+
if linkedin_url:
|
|
1316
|
+
filters.append({
|
|
1317
|
+
"propertyName": "hs_linkedin_url", "operator": "EQ", "value": linkedin_url
|
|
1318
|
+
})
|
|
1319
|
+
if phone_number:
|
|
1320
|
+
filters.append({
|
|
1321
|
+
"propertyName": "phone", "operator": "EQ", "value": phone_number
|
|
1322
|
+
})
|
|
1323
|
+
|
|
1324
|
+
if not filters:
|
|
1325
|
+
raise ValueError("At least one search parameter must be provided (or hubspot_id).")
|
|
1326
|
+
|
|
1327
|
+
search_url = "https://api.hubapi.com/crm/v3/objects/leads/search"
|
|
1328
|
+
payload = {
|
|
1329
|
+
"filterGroups": [
|
|
1330
|
+
{
|
|
1331
|
+
"filters": filters
|
|
1332
|
+
}
|
|
1333
|
+
],
|
|
1334
|
+
"properties": ["firstname","lastname","email","phone","hs_linkedin_url"],
|
|
1335
|
+
"limit": 1
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
async with session.post(search_url, headers=headers, json=payload) as resp:
|
|
1339
|
+
data = await resp.json()
|
|
1340
|
+
if resp.status != 200:
|
|
1341
|
+
raise Exception(f"Error searching lead: {resp.status} => {data}")
|
|
1342
|
+
|
|
1343
|
+
results = data.get("results", [])
|
|
1344
|
+
if not results:
|
|
1345
|
+
raise Exception("No lead found with the provided parameters.")
|
|
1346
|
+
lead_info = results[0]
|
|
1347
|
+
|
|
1348
|
+
# By here, we have a lead object with "id" in lead_info["id"]
|
|
1349
|
+
lead_id = lead_info["id"]
|
|
1350
|
+
|
|
1351
|
+
# ---------------------------------------------------------
|
|
1352
|
+
# (Optional) fetch associated companies
|
|
1353
|
+
# ---------------------------------------------------------
|
|
1354
|
+
assoc_url = f"https://api.hubapi.com/crm/v3/objects/leads/{lead_id}/associations/companies"
|
|
1355
|
+
async with session.get(assoc_url, headers=headers) as resp:
|
|
1356
|
+
companies_data = await resp.json()
|
|
1357
|
+
if resp.status == 200:
|
|
1358
|
+
lead_info["companies"] = companies_data.get("results", [])
|
|
1359
|
+
else:
|
|
1360
|
+
lead_info["companies"] = []
|
|
1361
|
+
|
|
1362
|
+
# ---------------------------------------------------------
|
|
1363
|
+
# (Optional) fetch associated notes
|
|
1364
|
+
# ---------------------------------------------------------
|
|
1365
|
+
notes_url = f"https://api.hubapi.com/crm/v3/objects/leads/{lead_id}/associations/notes"
|
|
1366
|
+
async with session.get(notes_url, headers=headers) as resp:
|
|
1367
|
+
notes_data = await resp.json()
|
|
1368
|
+
if resp.status == 200:
|
|
1369
|
+
lead_info["notes"] = notes_data.get("results", [])
|
|
1370
|
+
else:
|
|
1371
|
+
lead_info["notes"] = []
|
|
1372
|
+
|
|
1373
|
+
# ---------------------------------------------------------
|
|
1374
|
+
# (Optional) fetch associated calls, tasks, etc.
|
|
1375
|
+
# If you want to mirror "activities," you'd do:
|
|
1376
|
+
# calls => /leads/{id}/associations/calls
|
|
1377
|
+
# tasks => /leads/{id}/associations/tasks
|
|
1378
|
+
# etc.
|
|
1379
|
+
# (Skipping here for brevity)
|
|
1380
|
+
# ---------------------------------------------------------
|
|
1381
|
+
|
|
1382
|
+
return lead_info
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
# --------------------------------------------------------------------
|
|
1386
|
+
# 2) Fetch HubSpot Contact Info (V3) with optional custom tags
|
|
1387
|
+
# --------------------------------------------------------------------
|
|
1388
|
+
@assistant_tool
|
|
1389
|
+
async def fetch_hubspot_contact_info(
|
|
1390
|
+
hubspot_id: str = None,
|
|
1391
|
+
email: str = None,
|
|
1392
|
+
tool_config: Optional[List[Dict]] = None,
|
|
1393
|
+
custom_tag_property_name: str = None
|
|
1394
|
+
):
|
|
1395
|
+
"""
|
|
1396
|
+
Fetch contact information from HubSpot, including associated companies, notes, tasks, calls, meetings, and optionally custom tags.
|
|
1397
|
+
"""
|
|
1398
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
1399
|
+
if not hubspot_id and not email:
|
|
1400
|
+
raise ValueError("Either hubspot_id or email must be provided.")
|
|
1401
|
+
|
|
1402
|
+
headers = {
|
|
1403
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
1404
|
+
"Content-Type": "application/json"
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
# Prepare base properties list
|
|
1408
|
+
base_properties = ["email", "firstname", "lastname", "phone", "hs_linkedin_url"]
|
|
1409
|
+
if custom_tag_property_name:
|
|
1410
|
+
base_properties.append(custom_tag_property_name)
|
|
1411
|
+
|
|
1412
|
+
contact_info = None
|
|
1413
|
+
|
|
1414
|
+
async with aiohttp.ClientSession() as session:
|
|
1415
|
+
# ---------------------------------------------------------
|
|
1416
|
+
# 1) If we have hubspot_id, fetch directly
|
|
1417
|
+
# ---------------------------------------------------------
|
|
1418
|
+
if hubspot_id:
|
|
1419
|
+
url = f"https://api.hubapi.com/crm/v3/objects/contacts/{hubspot_id}"
|
|
1420
|
+
params = {"properties": ",".join(base_properties)}
|
|
1421
|
+
async with session.get(url, headers=headers, params=params) as resp:
|
|
1422
|
+
if resp.status != 200:
|
|
1423
|
+
return None
|
|
1424
|
+
data = await resp.json()
|
|
1425
|
+
contact_info = data
|
|
1426
|
+
else:
|
|
1427
|
+
# -----------------------------------------------------
|
|
1428
|
+
# 2) Otherwise, search by email to find contact_id
|
|
1429
|
+
# -----------------------------------------------------
|
|
1430
|
+
search_url = "https://api.hubapi.com/crm/v3/objects/contacts/search"
|
|
1431
|
+
payload = {
|
|
1432
|
+
"filterGroups": [
|
|
1433
|
+
{
|
|
1434
|
+
"filters": [
|
|
1435
|
+
{"propertyName": "email", "operator": "EQ", "value": email}
|
|
1436
|
+
]
|
|
1437
|
+
}
|
|
1438
|
+
],
|
|
1439
|
+
"properties": base_properties,
|
|
1440
|
+
"limit": 1
|
|
1441
|
+
}
|
|
1442
|
+
async with session.post(search_url, headers=headers, json=payload) as resp:
|
|
1443
|
+
data = await resp.json()
|
|
1444
|
+
if resp.status != 200:
|
|
1445
|
+
raise Exception(f"Error searching contact by email: {resp.status} => {data}")
|
|
1446
|
+
|
|
1447
|
+
results = data.get("results", [])
|
|
1448
|
+
if not results:
|
|
1449
|
+
raise Exception(f"No contact found with email '{email}'.")
|
|
1450
|
+
contact_info = results[0]
|
|
1451
|
+
|
|
1452
|
+
contact_id = contact_info["id"]
|
|
1453
|
+
|
|
1454
|
+
# Utility to fetch associated object
|
|
1455
|
+
async def fetch_associated_objects(object_type: str):
|
|
1456
|
+
url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}/associations/{object_type}"
|
|
1457
|
+
async with session.get(url, headers=headers) as resp:
|
|
1458
|
+
data = await resp.json()
|
|
1459
|
+
if resp.status == 200:
|
|
1460
|
+
return data.get("results", [])
|
|
1461
|
+
return []
|
|
1462
|
+
|
|
1463
|
+
contact_info["companies"] = await fetch_associated_objects("companies")
|
|
1464
|
+
contact_info["notes"] = await fetch_associated_objects("notes")
|
|
1465
|
+
contact_info["tasks"] = await fetch_associated_objects("tasks")
|
|
1466
|
+
contact_info["calls"] = await fetch_associated_objects("calls")
|
|
1467
|
+
contact_info["meetings"] = await fetch_associated_objects("meetings")
|
|
1468
|
+
|
|
1469
|
+
return contact_info
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
# --------------------------------------------------------------------
|
|
1474
|
+
# 3) Fetch Last N Activities for a Contact (V3 version)
|
|
1475
|
+
# "Activities" typically means calls, tasks, notes, meetings, emails, ...
|
|
1476
|
+
# Because there's no single "activities" object in v3, we do multiple calls
|
|
1477
|
+
# and combine the results, then pick the newest `num_events`.
|
|
1478
|
+
# --------------------------------------------------------------------
|
|
1479
|
+
@assistant_tool
|
|
1480
|
+
async def fetch_last_n_activities(
|
|
1481
|
+
email: str,
|
|
1482
|
+
num_events: int,
|
|
1483
|
+
tool_config: Optional[List[Dict]] = None
|
|
1484
|
+
):
|
|
1485
|
+
"""
|
|
1486
|
+
Fetch the last n "activities" for a contact (calls, tasks, notes, meetings, emails)
|
|
1487
|
+
by email, using HubSpot V3 associations.
|
|
1488
|
+
"""
|
|
1489
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
1490
|
+
if not email:
|
|
1491
|
+
raise ValueError("Email must be provided")
|
|
1492
|
+
|
|
1493
|
+
headers = {
|
|
1494
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
1495
|
+
"Content-Type": "application/json"
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
async with aiohttp.ClientSession() as session:
|
|
1499
|
+
# -----------------------------------------------------
|
|
1500
|
+
# 1) Find contact ID by email
|
|
1501
|
+
# -----------------------------------------------------
|
|
1502
|
+
search_url = "https://api.hubapi.com/crm/v3/objects/contacts/search"
|
|
1503
|
+
payload = {
|
|
1504
|
+
"filterGroups": [
|
|
1505
|
+
{
|
|
1506
|
+
"filters": [
|
|
1507
|
+
{"propertyName": "email", "operator": "EQ", "value": email}
|
|
1508
|
+
]
|
|
1509
|
+
}
|
|
1510
|
+
],
|
|
1511
|
+
"properties": ["email", "firstname", "lastname"],
|
|
1512
|
+
"limit": 1
|
|
1513
|
+
}
|
|
1514
|
+
async with session.post(search_url, headers=headers, json=payload) as response:
|
|
1515
|
+
data = await response.json()
|
|
1516
|
+
if response.status != 200:
|
|
1517
|
+
raise Exception(f"Error searching contact by email: {response.status} => {data}")
|
|
1518
|
+
results = data.get("results", [])
|
|
1519
|
+
if not results:
|
|
1520
|
+
raise Exception(f"No contact found with email '{email}'.")
|
|
1521
|
+
contact_id = results[0]["id"]
|
|
1522
|
+
|
|
1523
|
+
# -----------------------------------------------------
|
|
1524
|
+
# 2) Gather associations for each relevant activity type
|
|
1525
|
+
# We'll fetch calls, tasks, notes, meetings, and emails.
|
|
1526
|
+
# -----------------------------------------------------
|
|
1527
|
+
all_activity_records = []
|
|
1528
|
+
|
|
1529
|
+
for activity_type in ["calls", "tasks", "notes", "meetings", "emails"]:
|
|
1530
|
+
assoc_url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}/associations/{activity_type}"
|
|
1531
|
+
after = None
|
|
1532
|
+
activity_refs = []
|
|
1533
|
+
while True:
|
|
1534
|
+
params = {"limit": 100}
|
|
1535
|
+
if after:
|
|
1536
|
+
params["after"] = after
|
|
1537
|
+
|
|
1538
|
+
async with session.get(assoc_url, headers=headers, params=params) as resp:
|
|
1539
|
+
assoc_data = await resp.json()
|
|
1540
|
+
if resp.status != 200:
|
|
1541
|
+
raise Exception(f"Error fetching contact associations to {activity_type}: "
|
|
1542
|
+
f"{resp.status} => {assoc_data}")
|
|
1543
|
+
|
|
1544
|
+
results = assoc_data.get("results", [])
|
|
1545
|
+
activity_refs.extend(results)
|
|
1546
|
+
|
|
1547
|
+
paging = assoc_data.get("paging", {})
|
|
1548
|
+
after = paging.get("next", {}).get("after")
|
|
1549
|
+
if not after:
|
|
1550
|
+
break
|
|
1551
|
+
|
|
1552
|
+
if not activity_refs:
|
|
1553
|
+
continue
|
|
1554
|
+
|
|
1555
|
+
# Now we do a batch read for each object type
|
|
1556
|
+
# The V3 batch read endpoint: /crm/v3/objects/{activity_type}/batch/read
|
|
1557
|
+
# We'll sort by "hs_createdate" later.
|
|
1558
|
+
batch_url = f"https://api.hubapi.com/crm/v3/objects/{activity_type}/batch/read"
|
|
1559
|
+
object_ids = [ref["id"] for ref in activity_refs]
|
|
1560
|
+
payload = {
|
|
1561
|
+
"properties": ["hs_createdate","hs_note_body","hs_call_body","subject"], # or any relevant props
|
|
1562
|
+
"inputs": [{"id": oid} for oid in object_ids]
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
async with session.post(batch_url, headers=headers, json=payload) as resp:
|
|
1566
|
+
read_data = await resp.json()
|
|
1567
|
+
if resp.status != 200:
|
|
1568
|
+
raise Exception(f"Error batch reading {activity_type}: {resp.status} => {read_data}")
|
|
1569
|
+
objects = read_data.get("results", [])
|
|
1570
|
+
# Mark each object with a "type" field so we can distinguish them later
|
|
1571
|
+
for obj in objects:
|
|
1572
|
+
obj["activity_type"] = activity_type
|
|
1573
|
+
all_activity_records.extend(objects)
|
|
1574
|
+
|
|
1575
|
+
# -----------------------------------------------------
|
|
1576
|
+
# 3) Sort by created date descending, return top `num_events`
|
|
1577
|
+
# Typically "hs_createdate" is the creation timestamp
|
|
1578
|
+
# -----------------------------------------------------
|
|
1579
|
+
def get_created_date(obj):
|
|
1580
|
+
return obj.get("properties", {}).get("hs_createdate", "")
|
|
1581
|
+
|
|
1582
|
+
all_activity_records.sort(key=get_created_date, reverse=True)
|
|
1583
|
+
return all_activity_records[:num_events]
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
# --------------------------------------------------------------------
|
|
1587
|
+
# New Pydantic model to hold company info
|
|
1588
|
+
# --------------------------------------------------------------------
|
|
1589
|
+
class HubSpotCompanyInformation(BaseModel):
|
|
1590
|
+
organization_name: str = ""
|
|
1591
|
+
organization_website: str = ""
|
|
1592
|
+
primary_domain_of_organization: str = ""
|
|
1593
|
+
additional_properties: Dict[str, Any] = {}
|
|
1594
|
+
|
|
1595
|
+
# --------------------------------------------------------------------
|
|
1596
|
+
# Helper: transform raw company properties to HubSpotCompanyInformation
|
|
1597
|
+
# --------------------------------------------------------------------
|
|
1598
|
+
def transform_hubspot_company_properties_to_company_info(
|
|
1599
|
+
hubspot_company_properties: Dict[str, Any]
|
|
1600
|
+
) -> HubSpotCompanyInformation:
|
|
1601
|
+
"""
|
|
1602
|
+
Convert raw company properties from HubSpot into a HubSpotCompanyInformation object.
|
|
1603
|
+
|
|
1604
|
+
- organization_name -> from property "name"
|
|
1605
|
+
- organization_website -> from property "website" (if present; fallback to empty)
|
|
1606
|
+
- primary_domain_of_organization -> from property "domain"
|
|
1607
|
+
- Everything else into additional_properties["hubspot_company_information"] as a JSON string.
|
|
1608
|
+
"""
|
|
1609
|
+
result = {
|
|
1610
|
+
"organization_name": str(hubspot_company_properties.get("name", "")).strip(),
|
|
1611
|
+
"organization_website": str(hubspot_company_properties.get("website", "")).strip(),
|
|
1612
|
+
"primary_domain_of_organization": str(hubspot_company_properties.get("domain", "")).strip(),
|
|
1613
|
+
"additional_properties": {},
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
# Standard mapped keys we do NOT copy into additional_properties
|
|
1617
|
+
standard_keys = {"name", "website", "domain"}
|
|
1618
|
+
|
|
1619
|
+
# Gather everything else into additional_properties
|
|
1620
|
+
additional_info = {}
|
|
1621
|
+
for k, v in hubspot_company_properties.items():
|
|
1622
|
+
if k not in standard_keys and v is not None and str(v).strip():
|
|
1623
|
+
additional_info[k] = str(v).strip()
|
|
1624
|
+
|
|
1625
|
+
# Cleanup
|
|
1626
|
+
cleaned_dict = cleanup_properties(additional_info)
|
|
1627
|
+
result["additional_properties"]["hubspot_company_information"] = json.dumps(cleaned_dict)
|
|
1628
|
+
|
|
1629
|
+
return HubSpotCompanyInformation(**result)
|
|
1630
|
+
|
|
1631
|
+
# --------------------------------------------------------------------
|
|
1632
|
+
# Fetch a company list's memberships, batch-read the company details,
|
|
1633
|
+
# transform them, and return.
|
|
1634
|
+
# --------------------------------------------------------------------
|
|
1635
|
+
@assistant_tool
|
|
1636
|
+
async def fetch_hubspot_company_list_records(
|
|
1637
|
+
list_id: str,
|
|
1638
|
+
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
|
1639
|
+
limit: int = Query(10, gt=0, le=2000, description="Max number of records to return"),
|
|
1640
|
+
order_by: Optional[str] = Query(None, description="Field to order by"),
|
|
1641
|
+
order: Optional[str] = Query("asc", description="Sort order (asc or desc)"),
|
|
1642
|
+
tool_config: Optional[List[Dict]] = None
|
|
1643
|
+
) -> List[HubSpotCompanyInformation]:
|
|
1644
|
+
"""
|
|
1645
|
+
Fetch company records from a specific HubSpot list using the v3 API.
|
|
1646
|
+
- Accumulates up to `limit` company IDs from the list memberships.
|
|
1647
|
+
- Batch reads the company details.
|
|
1648
|
+
- Transforms to HubSpotCompanyInformation objects.
|
|
1649
|
+
- (Optionally) sorts locally by `order_by` ascending or descending.
|
|
1650
|
+
"""
|
|
1651
|
+
|
|
1652
|
+
# 1) Retrieve HubSpot Access Token
|
|
1653
|
+
HUBSPOT_ACCESS_TOKEN = get_hubspot_access_token(tool_config)
|
|
1654
|
+
if not list_id:
|
|
1655
|
+
raise ValueError("HubSpot company list ID must be provided")
|
|
1656
|
+
|
|
1657
|
+
headers = {
|
|
1658
|
+
"Authorization": f"Bearer {HUBSPOT_ACCESS_TOKEN}",
|
|
1659
|
+
"Content-Type": "application/json"
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
# Memberships URL for the given list
|
|
1663
|
+
membership_url = f"https://api.hubapi.com/crm/v3/lists/{list_id}/memberships"
|
|
1664
|
+
|
|
1665
|
+
# Accumulate company IDs
|
|
1666
|
+
accumulated_ids = []
|
|
1667
|
+
count_skipped = 0
|
|
1668
|
+
count_collected = 0
|
|
1669
|
+
after = None
|
|
1670
|
+
|
|
1671
|
+
async with aiohttp.ClientSession() as session:
|
|
1672
|
+
# 2) Page through the list memberships to find company IDs
|
|
1673
|
+
while True:
|
|
1674
|
+
if count_collected >= limit:
|
|
1675
|
+
break
|
|
1676
|
+
|
|
1677
|
+
# The # to request in this page
|
|
1678
|
+
fetch_amount = min(100, (limit + offset) - (count_skipped + count_collected))
|
|
1679
|
+
if fetch_amount <= 0:
|
|
1680
|
+
break
|
|
1681
|
+
|
|
1682
|
+
params = {"limit": fetch_amount}
|
|
1683
|
+
if after:
|
|
1684
|
+
params["after"] = after
|
|
1685
|
+
|
|
1686
|
+
async with session.get(membership_url, headers=headers, params=params) as response:
|
|
1687
|
+
if response.status != 200:
|
|
1688
|
+
error_details = await response.text()
|
|
1689
|
+
raise Exception(
|
|
1690
|
+
f"Error: Could not fetch list memberships. "
|
|
1691
|
+
f"Status code {response.status}. Details: {error_details}"
|
|
1692
|
+
)
|
|
1693
|
+
memberships_data = await response.json()
|
|
1694
|
+
memberships = memberships_data.get("results", [])
|
|
1695
|
+
after = memberships_data.get("paging", {}).get("next", {}).get("after")
|
|
1696
|
+
|
|
1697
|
+
for m in memberships:
|
|
1698
|
+
company_id = m["recordId"]
|
|
1699
|
+
if count_skipped < offset:
|
|
1700
|
+
count_skipped += 1
|
|
1701
|
+
else:
|
|
1702
|
+
accumulated_ids.append(company_id)
|
|
1703
|
+
count_collected += 1
|
|
1704
|
+
if count_collected >= limit:
|
|
1705
|
+
break
|
|
1706
|
+
|
|
1707
|
+
if not after or count_collected >= limit:
|
|
1708
|
+
break
|
|
1709
|
+
|
|
1710
|
+
# If no IDs, return an empty list
|
|
1711
|
+
if not accumulated_ids:
|
|
1712
|
+
return []
|
|
1713
|
+
|
|
1714
|
+
# 3) Batch read the company details
|
|
1715
|
+
batch_read_url = "https://api.hubapi.com/crm/v3/objects/companies/batch/read"
|
|
1716
|
+
|
|
1717
|
+
# For demonstration, let's fetch all standard/available props.
|
|
1718
|
+
# Or, you can fetch a subset, like ["name", "domain", "website", "linkedin_company_page"] etc.
|
|
1719
|
+
all_properties = await _fetch_all_company_properties(headers)
|
|
1720
|
+
|
|
1721
|
+
company_records = []
|
|
1722
|
+
batch_size = 100
|
|
1723
|
+
for i in range(0, len(accumulated_ids), batch_size):
|
|
1724
|
+
chunk_ids = accumulated_ids[i : i + batch_size]
|
|
1725
|
+
payload = {
|
|
1726
|
+
"properties": all_properties,
|
|
1727
|
+
"inputs": [{"id": cid} for cid in chunk_ids]
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
async with session.post(batch_read_url, headers=headers, json=payload) as r:
|
|
1731
|
+
if r.status != 200:
|
|
1732
|
+
error_details = await r.text()
|
|
1733
|
+
raise Exception(
|
|
1734
|
+
f"Error fetching batch company details. "
|
|
1735
|
+
f"Status code {r.status}. Details: {error_details}"
|
|
1736
|
+
)
|
|
1737
|
+
batch_data = await r.json()
|
|
1738
|
+
company_records.extend(batch_data.get("results", []))
|
|
1739
|
+
|
|
1740
|
+
# 4) Local sorting if requested
|
|
1741
|
+
if order_by:
|
|
1742
|
+
reverse_sort = (order.lower() == "desc")
|
|
1743
|
+
company_records.sort(
|
|
1744
|
+
key=lambda c: c.get("properties", {}).get(order_by, ""),
|
|
1745
|
+
reverse=reverse_sort
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
# 5) Transform each record into HubSpotCompanyInformation
|
|
1749
|
+
final_companies: List[HubSpotCompanyInformation] = []
|
|
1750
|
+
for record in company_records:
|
|
1751
|
+
properties = record.get("properties", {})
|
|
1752
|
+
company_info = transform_hubspot_company_properties_to_company_info(properties)
|
|
1753
|
+
final_companies.append(company_info)
|
|
1754
|
+
|
|
1755
|
+
return final_companies
|
|
1756
|
+
|
|
1757
|
+
# --------------------------------------------------------------------
|
|
1758
|
+
# Helper function to retrieve all properties available for companies
|
|
1759
|
+
# --------------------------------------------------------------------
|
|
1760
|
+
async def _fetch_all_company_properties(headers: Dict[str, str]) -> List[str]:
|
|
1761
|
+
"""
|
|
1762
|
+
Fetches all company property names via the HubSpot v3 properties API.
|
|
1763
|
+
You could also hardcode a list if you'd prefer fewer properties.
|
|
1764
|
+
"""
|
|
1765
|
+
properties_url = "https://api.hubapi.com/crm/v3/properties/companies"
|
|
1766
|
+
async with aiohttp.ClientSession() as session:
|
|
1767
|
+
async with session.get(properties_url, headers=headers) as prop_resp:
|
|
1768
|
+
if prop_resp.status != 200:
|
|
1769
|
+
error_details = await prop_resp.text()
|
|
1770
|
+
raise Exception(
|
|
1771
|
+
f"Error fetching company properties. "
|
|
1772
|
+
f"Status {prop_resp.status}. Details: {error_details}"
|
|
1773
|
+
)
|
|
1774
|
+
prop_data = await prop_resp.json()
|
|
1775
|
+
return [p["name"] for p in prop_data.get("results", [])]
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
@assistant_tool
|
|
1779
|
+
async def lookup_contact_by_name_and_domain(
|
|
1780
|
+
first_name: str,
|
|
1781
|
+
last_name: str,
|
|
1782
|
+
domain: str,
|
|
1783
|
+
tool_config: Optional[List[Dict]] = None
|
|
1784
|
+
) -> Union[HubSpotLeadInformation, dict]:
|
|
1785
|
+
"""
|
|
1786
|
+
Look up a HubSpot contact by first name, last name, and a "primary_domain_of_organization"
|
|
1787
|
+
(which you store on the contact record). If found, transform via transform_hubspot_contact_to_lead_info
|
|
1788
|
+
and return the HubSpotLeadInformation object; otherwise return {}.
|
|
1789
|
+
"""
|
|
1790
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
1791
|
+
if not HUBSPOT_API_KEY:
|
|
1792
|
+
return {}
|
|
1793
|
+
|
|
1794
|
+
if not all([first_name, last_name, domain]):
|
|
1795
|
+
return {}
|
|
1796
|
+
|
|
1797
|
+
# 1) Build filters
|
|
1798
|
+
filters = [
|
|
1799
|
+
{"propertyName": "firstname", "operator": "EQ", "value": first_name},
|
|
1800
|
+
{"propertyName": "lastname", "operator": "EQ", "value": last_name},
|
|
1801
|
+
{"propertyName": "primary_domain_of_organization", "operator": "EQ", "value": domain},
|
|
1802
|
+
]
|
|
1803
|
+
|
|
1804
|
+
# 2) Perform the search
|
|
1805
|
+
search_response = await search_hubspot_objects(
|
|
1806
|
+
object_type="contacts",
|
|
1807
|
+
filters=filters,
|
|
1808
|
+
limit=1,
|
|
1809
|
+
tool_config=tool_config,
|
|
1810
|
+
properties=[ # request at least these properties
|
|
1811
|
+
"firstname", "lastname", "email", "phone", "jobtitle",
|
|
1812
|
+
"headline", "primary_domain_of_organization",
|
|
1813
|
+
"company", "domain" # or any additional fields you need
|
|
1814
|
+
]
|
|
1815
|
+
)
|
|
1816
|
+
|
|
1817
|
+
# 3) Check if results exist
|
|
1818
|
+
results = search_response.get("results", [])
|
|
1819
|
+
if not results:
|
|
1820
|
+
return {}
|
|
1821
|
+
|
|
1822
|
+
# 4) Take the first match and transform
|
|
1823
|
+
contact_record = results[0]
|
|
1824
|
+
contact_properties = contact_record.get("properties", {})
|
|
1825
|
+
transformed = transform_hubspot_contact_to_lead_info(contact_properties)
|
|
1826
|
+
return transformed
|
|
1827
|
+
|
|
1828
|
+
|
|
1829
|
+
@assistant_tool
|
|
1830
|
+
async def lookup_contact_by_email(
|
|
1831
|
+
email: str,
|
|
1832
|
+
tool_config: Optional[List[Dict]] = None
|
|
1833
|
+
) -> Union[HubSpotLeadInformation, dict]:
|
|
1834
|
+
"""
|
|
1835
|
+
Look up a HubSpot contact by email. If found, transform via transform_hubspot_contact_to_lead_info
|
|
1836
|
+
and return the HubSpotLeadInformation object; otherwise return {}.
|
|
1837
|
+
"""
|
|
1838
|
+
if not email:
|
|
1839
|
+
return {}
|
|
1840
|
+
|
|
1841
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
1842
|
+
if not HUBSPOT_API_KEY:
|
|
1843
|
+
return {}
|
|
1844
|
+
|
|
1845
|
+
# 1) Build filter
|
|
1846
|
+
filters = [
|
|
1847
|
+
{"propertyName": "email", "operator": "EQ", "value": email},
|
|
1848
|
+
]
|
|
1849
|
+
|
|
1850
|
+
# 2) Perform the search
|
|
1851
|
+
search_response = await search_hubspot_objects(
|
|
1852
|
+
object_type="contacts",
|
|
1853
|
+
filters=filters,
|
|
1854
|
+
limit=1,
|
|
1855
|
+
tool_config=tool_config,
|
|
1856
|
+
properties=[
|
|
1857
|
+
"firstname", "lastname", "email", "phone", "jobtitle",
|
|
1858
|
+
"headline", "primary_domain_of_organization",
|
|
1859
|
+
"company", "domain"
|
|
1860
|
+
]
|
|
1861
|
+
)
|
|
1862
|
+
|
|
1863
|
+
# 3) Check if results exist
|
|
1864
|
+
results = search_response.get("results", [])
|
|
1865
|
+
if not results:
|
|
1866
|
+
return {}
|
|
1867
|
+
|
|
1868
|
+
# 4) Take the first match and transform
|
|
1869
|
+
contact_record = results[0]
|
|
1870
|
+
contact_properties = contact_record.get("properties", {})
|
|
1871
|
+
transformed = transform_hubspot_contact_to_lead_info(contact_properties)
|
|
1872
|
+
return transformed
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
1880
|
+
# Utility helpers
|
|
1881
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
1882
|
+
def md_to_html(md: str) -> str:
|
|
1883
|
+
"""Render Markdown to HTML that HubSpot notes can display."""
|
|
1884
|
+
return markdown(md, extensions=["extra", "sane_lists"])
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
def html_to_text(html_str: str) -> str:
|
|
1888
|
+
"""Strip tags so the value is safe for a plain-text HS property."""
|
|
1889
|
+
return BeautifulSoup(html_str, "html.parser").get_text("\n")
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
def build_note_html(cv: Dict[str, Any]) -> str:
|
|
1893
|
+
"""Create a neat, labelled HTML block for the HubSpot note body."""
|
|
1894
|
+
parts: List[str] = []
|
|
1895
|
+
parts.append("<p><strong>Dhisana AI Lead Research & Engagement Summary</strong></p>")
|
|
1896
|
+
|
|
1897
|
+
def para(label: str, val: Optional[str]):
|
|
1898
|
+
if val:
|
|
1899
|
+
parts.append(
|
|
1900
|
+
f"<p><strong>{html.escape(label)}:</strong> "
|
|
1901
|
+
f"{html.escape(val)}</p>"
|
|
1902
|
+
)
|
|
1903
|
+
|
|
1904
|
+
para("Name", f"{cv.get('first_name', '')} {cv.get('last_name', '')}".strip())
|
|
1905
|
+
para("Email", cv.get("email"))
|
|
1906
|
+
para("LinkedIn", cv.get("user_linkedin_url"))
|
|
1907
|
+
para("Phone", cv.get("phone"))
|
|
1908
|
+
para("Job Title", cv.get("job_title"))
|
|
1909
|
+
para("Organization", cv.get("organization_name"))
|
|
1910
|
+
para("Domain", cv.get("organization_domain"))
|
|
1911
|
+
|
|
1912
|
+
md_summary = cv.get("research_summary")
|
|
1913
|
+
if md_summary:
|
|
1914
|
+
parts.append("<p><strong>Research Summary:</strong></p>")
|
|
1915
|
+
parts.append(md_to_html(md_summary))
|
|
1916
|
+
|
|
1917
|
+
return "".join(parts)
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
1921
|
+
# Mapping between internal names and HubSpot property names
|
|
1922
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
1923
|
+
PROPERTY_MAPPING: Dict[str, str] = {
|
|
1924
|
+
"first_name": "firstname",
|
|
1925
|
+
"last_name": "lastname",
|
|
1926
|
+
"email": "email",
|
|
1927
|
+
"phone": "phone",
|
|
1928
|
+
"job_title": "jobtitle",
|
|
1929
|
+
"primary_domain_of_organization": "domain",
|
|
1930
|
+
"user_linkedin_url": "hs_linkedin_url",
|
|
1931
|
+
"research_summary": "dhisana_research_summary",
|
|
1932
|
+
"organization_name": "company",
|
|
1933
|
+
# add "website": "website" if present in your schema
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
# Properties that we conditionally fill-in (only if empty in HS)
|
|
1937
|
+
CONDITIONAL_UPDATE_PROPS = {
|
|
1938
|
+
"jobtitle",
|
|
1939
|
+
"company",
|
|
1940
|
+
"phone",
|
|
1941
|
+
"domain",
|
|
1942
|
+
"hs_linkedin_url",
|
|
1943
|
+
"website",
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
# Properties we *never* modify once the record exists
|
|
1947
|
+
IMMUTABLE_ON_UPDATE = {"firstname", "lastname", "email"}
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
def _is_empty(val: Optional[str]) -> bool:
|
|
1951
|
+
return val is None or (isinstance(val, str) and not val.strip())
|
|
1952
|
+
|
|
1953
|
+
|
|
1954
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
1955
|
+
# Main upsert entry-point (updated for flexible tag field)
|
|
1956
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
1957
|
+
async def update_crm_contact_record_function(
|
|
1958
|
+
contact_values: Dict[str, Any],
|
|
1959
|
+
is_update: bool,
|
|
1960
|
+
hubspot_contact_id: Optional[str] = None,
|
|
1961
|
+
email: Optional[str] = None,
|
|
1962
|
+
first_name: Optional[str] = None,
|
|
1963
|
+
last_name: Optional[str] = None,
|
|
1964
|
+
company_domain: Optional[str] = None,
|
|
1965
|
+
user_linkedin_url: Optional[str] = None,
|
|
1966
|
+
tags: Optional[List[str]] = None,
|
|
1967
|
+
tag_property: Optional[str] = None, # ← NEW
|
|
1968
|
+
tool_config: Optional[List[Dict]] = None,
|
|
1969
|
+
) -> Dict[str, Any]:
|
|
1970
|
+
"""
|
|
1971
|
+
Create or update a HubSpot contact.
|
|
1972
|
+
|
|
1973
|
+
Args
|
|
1974
|
+
----
|
|
1975
|
+
tag_property:
|
|
1976
|
+
The HS property that stores your semicolon-delimited tag list
|
|
1977
|
+
(e.g. ``"dhisana_contact_tags"``).
|
|
1978
|
+
• If supplied *and* present in the portal, it will be used.
|
|
1979
|
+
• If absent, we silently fall back to ``"my_tags"`` if available.
|
|
1980
|
+
• If neither exists, tags are skipped without error.
|
|
1981
|
+
"""
|
|
1982
|
+
|
|
1983
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
1984
|
+
headers = {
|
|
1985
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
1986
|
+
"Content-Type": "application/json",
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
valid_props = await _fetch_all_contact_properties(headers)
|
|
1990
|
+
|
|
1991
|
+
# ─── 0) Build note HTML + plain-text summary ─────────────────────────────
|
|
1992
|
+
note_html: str = build_note_html(contact_values)
|
|
1993
|
+
note_plain: str = html_to_text(note_html)
|
|
1994
|
+
|
|
1995
|
+
# ─── 1) Map contact_values → HS property dict ────────────────────────────
|
|
1996
|
+
incoming_props: Dict[str, Any] = {}
|
|
1997
|
+
for k, v in contact_values.items():
|
|
1998
|
+
if v is None:
|
|
1999
|
+
continue
|
|
2000
|
+
mapped = PROPERTY_MAPPING.get(k)
|
|
2001
|
+
if mapped and mapped in valid_props:
|
|
2002
|
+
incoming_props[mapped] = v
|
|
2003
|
+
|
|
2004
|
+
# ─── 1-b) Handle tags ----------------------------------------------------
|
|
2005
|
+
if tags:
|
|
2006
|
+
# pick the first tag field that actually exists
|
|
2007
|
+
prop_name: Optional[str] = None
|
|
2008
|
+
if tag_property and tag_property in valid_props:
|
|
2009
|
+
prop_name = tag_property
|
|
2010
|
+
elif "my_tags" in valid_props:
|
|
2011
|
+
prop_name = "my_tags"
|
|
2012
|
+
|
|
2013
|
+
if prop_name:
|
|
2014
|
+
incoming_props[prop_name] = ";".join(tags)
|
|
2015
|
+
|
|
2016
|
+
# ─── 2) Upsert logic ─────────────────────────────────────────────────────
|
|
2017
|
+
found_contact_id: Optional[str] = None
|
|
2018
|
+
if is_update:
|
|
2019
|
+
found_contact_id = hubspot_contact_id or await _find_existing_contact(
|
|
2020
|
+
email,
|
|
2021
|
+
user_linkedin_url,
|
|
2022
|
+
first_name,
|
|
2023
|
+
last_name,
|
|
2024
|
+
company_domain,
|
|
2025
|
+
valid_props,
|
|
2026
|
+
tool_config,
|
|
2027
|
+
)
|
|
2028
|
+
|
|
2029
|
+
if found_contact_id:
|
|
2030
|
+
# ─── Existing contact -------------------------------------------------
|
|
2031
|
+
contact_data = await _get_contact_by_id(found_contact_id, headers)
|
|
2032
|
+
current = contact_data.get("properties", {})
|
|
2033
|
+
|
|
2034
|
+
hubspot_props: Dict[str, Any] = {}
|
|
2035
|
+
for prop, val in incoming_props.items():
|
|
2036
|
+
if prop in IMMUTABLE_ON_UPDATE:
|
|
2037
|
+
continue
|
|
2038
|
+
if prop in CONDITIONAL_UPDATE_PROPS:
|
|
2039
|
+
# only fill if currently blank
|
|
2040
|
+
if _is_empty(current.get(prop)):
|
|
2041
|
+
hubspot_props[prop] = val
|
|
2042
|
+
else:
|
|
2043
|
+
hubspot_props[prop] = val
|
|
2044
|
+
|
|
2045
|
+
# merge/update dhisana_lead_information
|
|
2046
|
+
if "dhisana_lead_information" in valid_props:
|
|
2047
|
+
if note_plain:
|
|
2048
|
+
merged = (current.get("dhisana_lead_information") or "").strip()
|
|
2049
|
+
merged = f"{merged}\n\n{note_plain}" if merged else note_plain
|
|
2050
|
+
hubspot_props["dhisana_lead_information"] = merged
|
|
2051
|
+
elif note_html:
|
|
2052
|
+
await create_hubspot_note_for_customer(
|
|
2053
|
+
customer_id=found_contact_id,
|
|
2054
|
+
note=note_html,
|
|
2055
|
+
tool_config=tool_config,
|
|
2056
|
+
)
|
|
2057
|
+
|
|
2058
|
+
if hubspot_props:
|
|
2059
|
+
update_url = f"https://api.hubapi.com/crm/v3/objects/contacts/{found_contact_id}"
|
|
2060
|
+
async with aiohttp.ClientSession() as s:
|
|
2061
|
+
async with s.patch(
|
|
2062
|
+
update_url, headers=headers, json={"properties": hubspot_props}
|
|
2063
|
+
) as r:
|
|
2064
|
+
res = await r.json()
|
|
2065
|
+
if r.status != 200:
|
|
2066
|
+
raise RuntimeError(f"Update failed {r.status}: {res}")
|
|
2067
|
+
return res
|
|
2068
|
+
return contact_data
|
|
2069
|
+
|
|
2070
|
+
# ─── Create new contact ──────────────────────────────────────────────────
|
|
2071
|
+
return await _create_new_contact_with_note_or_property(
|
|
2072
|
+
incoming_props,
|
|
2073
|
+
note_html,
|
|
2074
|
+
note_plain,
|
|
2075
|
+
valid_props,
|
|
2076
|
+
headers,
|
|
2077
|
+
tool_config,
|
|
2078
|
+
)
|
|
2079
|
+
|
|
2080
|
+
|
|
2081
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2082
|
+
# Contact creation helper
|
|
2083
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2084
|
+
async def _create_new_contact_with_note_or_property(
|
|
2085
|
+
properties: Dict[str, Any],
|
|
2086
|
+
note_html: str,
|
|
2087
|
+
note_plain: str,
|
|
2088
|
+
valid_props: List[str],
|
|
2089
|
+
headers: Dict[str, str],
|
|
2090
|
+
tool_config: Optional[List[Dict]],
|
|
2091
|
+
) -> Dict[str, Any]:
|
|
2092
|
+
"""Create contact, store lead info either in property or as a note."""
|
|
2093
|
+
if "dhisana_lead_information" in valid_props and note_plain:
|
|
2094
|
+
properties["dhisana_lead_information"] = note_plain
|
|
2095
|
+
|
|
2096
|
+
create_result = await _create_new_hubspot_contact(properties, headers)
|
|
2097
|
+
|
|
2098
|
+
if note_html and "dhisana_lead_information" not in valid_props:
|
|
2099
|
+
new_id = create_result.get("id")
|
|
2100
|
+
if new_id:
|
|
2101
|
+
await create_hubspot_note_for_customer(
|
|
2102
|
+
customer_id=new_id, note=note_html, tool_config=tool_config
|
|
2103
|
+
)
|
|
2104
|
+
return create_result
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2108
|
+
# Misc. helpers
|
|
2109
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2110
|
+
async def _find_existing_contact(
|
|
2111
|
+
email: Optional[str],
|
|
2112
|
+
linkedin_url: Optional[str],
|
|
2113
|
+
first: Optional[str],
|
|
2114
|
+
last: Optional[str],
|
|
2115
|
+
domain: Optional[str],
|
|
2116
|
+
valid_props: List[str],
|
|
2117
|
+
tool_cfg: Optional[List[Dict]],
|
|
2118
|
+
) -> Optional[str]:
|
|
2119
|
+
"""Return contact ID if a matching record exists, else None."""
|
|
2120
|
+
filters = []
|
|
2121
|
+
if email:
|
|
2122
|
+
filters.append({"propertyName": "email", "operator": "EQ", "value": email})
|
|
2123
|
+
elif linkedin_url and "hs_linkedin_url" in valid_props:
|
|
2124
|
+
filters.append(
|
|
2125
|
+
{"propertyName": "hs_linkedin_url", "operator": "EQ", "value": linkedin_url}
|
|
2126
|
+
)
|
|
2127
|
+
elif first and last and domain:
|
|
2128
|
+
filters.extend(
|
|
2129
|
+
[
|
|
2130
|
+
{"propertyName": "firstname", "operator": "EQ", "value": first},
|
|
2131
|
+
{"propertyName": "lastname", "operator": "EQ", "value": last},
|
|
2132
|
+
{"propertyName": "domain", "operator": "EQ", "value": domain},
|
|
2133
|
+
]
|
|
2134
|
+
)
|
|
2135
|
+
|
|
2136
|
+
if not filters:
|
|
2137
|
+
return None
|
|
2138
|
+
|
|
2139
|
+
res = await search_hubspot_objects(
|
|
2140
|
+
object_type="contacts",
|
|
2141
|
+
filters=filters,
|
|
2142
|
+
limit=1,
|
|
2143
|
+
tool_config=tool_cfg,
|
|
2144
|
+
properties=[
|
|
2145
|
+
"email",
|
|
2146
|
+
"firstname",
|
|
2147
|
+
"lastname",
|
|
2148
|
+
"domain",
|
|
2149
|
+
"dhisana_lead_information",
|
|
2150
|
+
"company",
|
|
2151
|
+
"jobtitle",
|
|
2152
|
+
"phone",
|
|
2153
|
+
"hs_linkedin_url",
|
|
2154
|
+
],
|
|
2155
|
+
)
|
|
2156
|
+
hits = res.get("results", [])
|
|
2157
|
+
return hits[0]["id"] if hits else None
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
async def _create_new_hubspot_contact(
|
|
2161
|
+
properties: Dict[str, Any], headers: Dict[str, str]
|
|
2162
|
+
) -> Dict[str, Any]:
|
|
2163
|
+
url = "https://api.hubapi.com/crm/v3/objects/contacts"
|
|
2164
|
+
async with aiohttp.ClientSession() as s:
|
|
2165
|
+
async with s.post(url, headers=headers, json={"properties": properties}) as r:
|
|
2166
|
+
data = await r.json()
|
|
2167
|
+
if r.status not in (200, 201):
|
|
2168
|
+
raise RuntimeError(f"Create contact failed {r.status}: {data}")
|
|
2169
|
+
return data
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
async def _fetch_all_contact_properties(headers: Dict[str, str]) -> List[str]:
|
|
2173
|
+
url = "https://api.hubapi.com/crm/v3/properties/contacts"
|
|
2174
|
+
async with aiohttp.ClientSession() as s:
|
|
2175
|
+
async with s.get(url, headers=headers) as r:
|
|
2176
|
+
if r.status != 200:
|
|
2177
|
+
raise RuntimeError(f"Prop fetch failed {r.status}: {await r.text()}")
|
|
2178
|
+
data = await r.json()
|
|
2179
|
+
return [p["name"] for p in data.get("results", [])]
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
async def _get_contact_by_id(contact_id: str, headers: Dict[str, str]) -> Dict[str, Any]:
|
|
2183
|
+
url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}"
|
|
2184
|
+
async with aiohttp.ClientSession() as s:
|
|
2185
|
+
async with s.get(url, headers=headers) as r:
|
|
2186
|
+
data = await r.json()
|
|
2187
|
+
if r.status != 200:
|
|
2188
|
+
raise RuntimeError(f"Fetch contact {contact_id} failed: {r.status} {data}")
|
|
2189
|
+
return data
|
|
2190
|
+
|
|
2191
|
+
|
|
2192
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2193
|
+
# Note creation
|
|
2194
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2195
|
+
async def create_hubspot_note_for_customer(
|
|
2196
|
+
customer_id: str | None = None,
|
|
2197
|
+
email: str | None = None,
|
|
2198
|
+
note: str | None = None,
|
|
2199
|
+
tool_config: Optional[List[Dict]] = None,
|
|
2200
|
+
):
|
|
2201
|
+
"""
|
|
2202
|
+
Create a rich-text note and attach it to a contact (associationTypeId 202).
|
|
2203
|
+
`note` must be **HTML**.
|
|
2204
|
+
"""
|
|
2205
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
2206
|
+
if not (customer_id or email):
|
|
2207
|
+
raise ValueError("Either customer_id or email is required.")
|
|
2208
|
+
if not note:
|
|
2209
|
+
raise ValueError("Note content must be provided.")
|
|
2210
|
+
|
|
2211
|
+
headers = {
|
|
2212
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
2213
|
+
"Content-Type": "application/json",
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
async with aiohttp.ClientSession() as s:
|
|
2217
|
+
if not customer_id:
|
|
2218
|
+
search_url = "https://api.hubapi.com/crm/v3/objects/contacts/search"
|
|
2219
|
+
payload = {
|
|
2220
|
+
"filterGroups": [
|
|
2221
|
+
{
|
|
2222
|
+
"filters": [
|
|
2223
|
+
{"propertyName": "email", "operator": "EQ", "value": email}
|
|
2224
|
+
]
|
|
2225
|
+
}
|
|
2226
|
+
],
|
|
2227
|
+
"limit": 1,
|
|
2228
|
+
}
|
|
2229
|
+
async with s.post(search_url, headers=headers, json=payload) as r:
|
|
2230
|
+
js = await r.json()
|
|
2231
|
+
if r.status != 200 or not js.get("results"):
|
|
2232
|
+
raise RuntimeError(f"Contact lookup failed {r.status}: {js}")
|
|
2233
|
+
customer_id = js["results"][0]["id"]
|
|
2234
|
+
|
|
2235
|
+
create_url = "https://api.hubapi.com/crm/v3/objects/notes"
|
|
2236
|
+
payload = {
|
|
2237
|
+
"properties": {
|
|
2238
|
+
"hs_note_body": note,
|
|
2239
|
+
"hs_timestamp": int(time.time() * 1000),
|
|
2240
|
+
},
|
|
2241
|
+
"associations": [
|
|
2242
|
+
{
|
|
2243
|
+
"to": {"id": customer_id, "type": "contact"},
|
|
2244
|
+
"types": [
|
|
2245
|
+
{
|
|
2246
|
+
"associationCategory": "HUBSPOT_DEFINED",
|
|
2247
|
+
"associationTypeId": 202,
|
|
2248
|
+
}
|
|
2249
|
+
],
|
|
2250
|
+
}
|
|
2251
|
+
],
|
|
2252
|
+
}
|
|
2253
|
+
async with s.post(create_url, headers=headers, json=payload) as r:
|
|
2254
|
+
res = await r.json()
|
|
2255
|
+
if r.status != 201:
|
|
2256
|
+
raise RuntimeError(f"Create note failed {r.status}: {res}")
|
|
2257
|
+
return res
|
|
2258
|
+
|
|
2259
|
+
|
|
2260
|
+
|
|
2261
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2262
|
+
# Mapping between internal names ⇢ HubSpot company property names
|
|
2263
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2264
|
+
COMPANY_PROPERTY_MAPPING: Dict[str, str] = {
|
|
2265
|
+
"organization_name": "name",
|
|
2266
|
+
"primary_domain_of_organization": "domain",
|
|
2267
|
+
"organization_website": "website",
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
# Only fill these if currently blank in HS
|
|
2271
|
+
CONDITIONAL_UPDATE_PROPS = {"domain", "website"}
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2275
|
+
# Main upsert entry-point
|
|
2276
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2277
|
+
async def update_crm_company_record_function(
|
|
2278
|
+
company_values: Dict[str, Any],
|
|
2279
|
+
is_update: bool,
|
|
2280
|
+
hubspot_company_id: Optional[str] = None,
|
|
2281
|
+
organization_name: Optional[str] = None,
|
|
2282
|
+
domain: Optional[str] = None,
|
|
2283
|
+
organization_website: Optional[str] = None,
|
|
2284
|
+
tool_config: Optional[List[Dict]] = None,
|
|
2285
|
+
) -> Dict[str, Any]:
|
|
2286
|
+
"""
|
|
2287
|
+
Create **or** update a HubSpot *company*.
|
|
2288
|
+
|
|
2289
|
+
Parameters
|
|
2290
|
+
----------
|
|
2291
|
+
company_values:
|
|
2292
|
+
Arbitrary key/value dict using **internal** names
|
|
2293
|
+
(``organization_name``, ``primary_domain_of_organization``, etc.)
|
|
2294
|
+
is_update:
|
|
2295
|
+
• ``True`` → do a best-effort lookup + patch if found
|
|
2296
|
+
• ``False`` → always create a fresh company
|
|
2297
|
+
hubspot_company_id:
|
|
2298
|
+
Pass a known HS ID to force an update to that record.
|
|
2299
|
+
"""
|
|
2300
|
+
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
2301
|
+
headers = {
|
|
2302
|
+
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
2303
|
+
"Content-Type": "application/json",
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
# ─── 1) Resolve allowed property names ───────────────────────────────────
|
|
2307
|
+
valid_props = await _fetch_all_company_properties(headers)
|
|
2308
|
+
|
|
2309
|
+
incoming_props: Dict[str, Any] = {}
|
|
2310
|
+
# a) from dict …
|
|
2311
|
+
for k, v in company_values.items():
|
|
2312
|
+
if v is None:
|
|
2313
|
+
continue
|
|
2314
|
+
mapped = COMPANY_PROPERTY_MAPPING.get(k)
|
|
2315
|
+
if mapped and mapped in valid_props:
|
|
2316
|
+
incoming_props[mapped] = v
|
|
2317
|
+
|
|
2318
|
+
# b) from explicit kwargs (fallbacks)
|
|
2319
|
+
if organization_name and "name" in valid_props:
|
|
2320
|
+
incoming_props.setdefault("name", organization_name)
|
|
2321
|
+
if domain and "domain" in valid_props:
|
|
2322
|
+
incoming_props.setdefault("domain", domain)
|
|
2323
|
+
if organization_website and "website" in valid_props:
|
|
2324
|
+
incoming_props.setdefault("website", organization_website)
|
|
2325
|
+
|
|
2326
|
+
# ─── 2) Upsert logic ─────────────────────────────────────────────────────
|
|
2327
|
+
found_company_id: Optional[str] = None
|
|
2328
|
+
if is_update:
|
|
2329
|
+
found_company_id = hubspot_company_id or await _find_existing_company(
|
|
2330
|
+
domain=domain,
|
|
2331
|
+
name=organization_name,
|
|
2332
|
+
valid_props=valid_props,
|
|
2333
|
+
tool_cfg=tool_config,
|
|
2334
|
+
)
|
|
2335
|
+
|
|
2336
|
+
if found_company_id:
|
|
2337
|
+
logging.info("↻ Updating existing company %s", found_company_id)
|
|
2338
|
+
return await _patch_company(
|
|
2339
|
+
company_id=found_company_id,
|
|
2340
|
+
incoming_props=incoming_props,
|
|
2341
|
+
headers=headers,
|
|
2342
|
+
)
|
|
2343
|
+
|
|
2344
|
+
logging.info("⊕ Creating new company with props: %s", incoming_props)
|
|
2345
|
+
return await _create_new_hubspot_company(incoming_props, headers)
|
|
2346
|
+
|
|
2347
|
+
|
|
2348
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2349
|
+
# Internal helpers
|
|
2350
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2351
|
+
async def _patch_company(
|
|
2352
|
+
company_id: str,
|
|
2353
|
+
incoming_props: Dict[str, Any],
|
|
2354
|
+
headers: Dict[str, str],
|
|
2355
|
+
) -> Dict[str, Any]:
|
|
2356
|
+
"""Patch only the changed / conditionally-empty properties."""
|
|
2357
|
+
current = await _get_company_by_id(company_id, headers)
|
|
2358
|
+
if not current:
|
|
2359
|
+
raise RuntimeError(f"Company {company_id} vanished during update step.")
|
|
2360
|
+
|
|
2361
|
+
current_props = current.get("properties", {})
|
|
2362
|
+
hubspot_props: Dict[str, Any] = {}
|
|
2363
|
+
|
|
2364
|
+
for prop, val in incoming_props.items():
|
|
2365
|
+
if prop in CONDITIONAL_UPDATE_PROPS:
|
|
2366
|
+
if _is_empty(current_props.get(prop)):
|
|
2367
|
+
hubspot_props[prop] = val
|
|
2368
|
+
else:
|
|
2369
|
+
hubspot_props[prop] = val
|
|
2370
|
+
|
|
2371
|
+
if not hubspot_props:
|
|
2372
|
+
logging.info("No new data to patch for company %s; skipping.", company_id)
|
|
2373
|
+
return current
|
|
2374
|
+
|
|
2375
|
+
url = f"https://api.hubapi.com/crm/v3/objects/companies/{company_id}"
|
|
2376
|
+
async with aiohttp.ClientSession() as s:
|
|
2377
|
+
async with s.patch(url, headers=headers, json={"properties": hubspot_props}) as r:
|
|
2378
|
+
res = await r.json()
|
|
2379
|
+
if r.status != 200:
|
|
2380
|
+
raise RuntimeError(f"Company update failed {r.status}: {res}")
|
|
2381
|
+
return res
|
|
2382
|
+
|
|
2383
|
+
|
|
2384
|
+
async def _create_new_hubspot_company(
|
|
2385
|
+
properties: Dict[str, Any], headers: Dict[str, str]
|
|
2386
|
+
) -> Dict[str, Any]:
|
|
2387
|
+
url = "https://api.hubapi.com/crm/v3/objects/companies"
|
|
2388
|
+
async with aiohttp.ClientSession() as s:
|
|
2389
|
+
async with s.post(url, headers=headers, json={"properties": properties}) as r:
|
|
2390
|
+
data = await r.json()
|
|
2391
|
+
if r.status not in (200, 201):
|
|
2392
|
+
raise RuntimeError(f"Create company failed {r.status}: {data}")
|
|
2393
|
+
return data
|
|
2394
|
+
|
|
2395
|
+
|
|
2396
|
+
async def _find_existing_company(
|
|
2397
|
+
domain: Optional[str],
|
|
2398
|
+
name: Optional[str],
|
|
2399
|
+
valid_props: List[str],
|
|
2400
|
+
tool_cfg: Optional[List[Dict]],
|
|
2401
|
+
) -> Optional[str]:
|
|
2402
|
+
"""Return company ID if a matching record exists, else None."""
|
|
2403
|
+
filters = []
|
|
2404
|
+
if domain:
|
|
2405
|
+
filters.append({"propertyName": "domain", "operator": "EQ", "value": domain})
|
|
2406
|
+
if not filters and name:
|
|
2407
|
+
filters.append({"propertyName": "name", "operator": "EQ", "value": name})
|
|
2408
|
+
|
|
2409
|
+
if not filters:
|
|
2410
|
+
return None
|
|
2411
|
+
|
|
2412
|
+
res = await search_hubspot_objects(
|
|
2413
|
+
object_type="companies",
|
|
2414
|
+
filters=filters,
|
|
2415
|
+
limit=1,
|
|
2416
|
+
tool_config=tool_cfg,
|
|
2417
|
+
properties=["name", "domain", "website"],
|
|
2418
|
+
)
|
|
2419
|
+
hits = res.get("results", [])
|
|
2420
|
+
return hits[0]["id"] if hits else None
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
async def _fetch_all_company_properties(headers: Dict[str, str]) -> List[str]:
|
|
2424
|
+
url = "https://api.hubapi.com/crm/v3/properties/companies"
|
|
2425
|
+
async with aiohttp.ClientSession() as s:
|
|
2426
|
+
async with s.get(url, headers=headers) as r:
|
|
2427
|
+
if r.status != 200:
|
|
2428
|
+
raise RuntimeError(f"Company prop fetch failed {r.status}: {await r.text()}")
|
|
2429
|
+
data = await r.json()
|
|
2430
|
+
return [p["name"] for p in data.get("results", [])]
|
|
2431
|
+
|
|
2432
|
+
|
|
2433
|
+
async def _get_company_by_id(company_id: str, headers: Dict[str, str]) -> Dict[str, Any]:
|
|
2434
|
+
url = f"https://api.hubapi.com/crm/v3/objects/companies/{company_id}"
|
|
2435
|
+
async with aiohttp.ClientSession() as s:
|
|
2436
|
+
async with s.get(url, headers=headers) as r:
|
|
2437
|
+
data = await r.json()
|
|
2438
|
+
if r.status != 200:
|
|
2439
|
+
raise RuntimeError(f"Fetch company {company_id} failed: {r.status} {data}")
|
|
2440
|
+
return data
|