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.
Files changed (102) hide show
  1. dhisana/__init__.py +1 -0
  2. dhisana/cli/__init__.py +1 -0
  3. dhisana/cli/cli.py +20 -0
  4. dhisana/cli/datasets.py +27 -0
  5. dhisana/cli/models.py +26 -0
  6. dhisana/cli/predictions.py +20 -0
  7. dhisana/schemas/__init__.py +1 -0
  8. dhisana/schemas/common.py +399 -0
  9. dhisana/schemas/sales.py +965 -0
  10. dhisana/ui/__init__.py +1 -0
  11. dhisana/ui/components.py +472 -0
  12. dhisana/utils/__init__.py +1 -0
  13. dhisana/utils/add_mapping.py +352 -0
  14. dhisana/utils/agent_tools.py +51 -0
  15. dhisana/utils/apollo_tools.py +1597 -0
  16. dhisana/utils/assistant_tool_tag.py +4 -0
  17. dhisana/utils/built_with_api_tools.py +282 -0
  18. dhisana/utils/cache_output_tools.py +98 -0
  19. dhisana/utils/cache_output_tools_local.py +78 -0
  20. dhisana/utils/check_email_validity_tools.py +717 -0
  21. dhisana/utils/check_for_intent_signal.py +107 -0
  22. dhisana/utils/check_linkedin_url_validity.py +209 -0
  23. dhisana/utils/clay_tools.py +43 -0
  24. dhisana/utils/clean_properties.py +135 -0
  25. dhisana/utils/company_utils.py +60 -0
  26. dhisana/utils/compose_salesnav_query.py +259 -0
  27. dhisana/utils/compose_search_query.py +759 -0
  28. dhisana/utils/compose_three_step_workflow.py +234 -0
  29. dhisana/utils/composite_tools.py +137 -0
  30. dhisana/utils/dataframe_tools.py +237 -0
  31. dhisana/utils/domain_parser.py +45 -0
  32. dhisana/utils/email_body_utils.py +72 -0
  33. dhisana/utils/email_parse_helpers.py +132 -0
  34. dhisana/utils/email_provider.py +375 -0
  35. dhisana/utils/enrich_lead_information.py +933 -0
  36. dhisana/utils/extract_email_content_for_llm.py +101 -0
  37. dhisana/utils/fetch_openai_config.py +129 -0
  38. dhisana/utils/field_validators.py +426 -0
  39. dhisana/utils/g2_tools.py +104 -0
  40. dhisana/utils/generate_content.py +41 -0
  41. dhisana/utils/generate_custom_message.py +271 -0
  42. dhisana/utils/generate_email.py +278 -0
  43. dhisana/utils/generate_email_response.py +465 -0
  44. dhisana/utils/generate_flow.py +102 -0
  45. dhisana/utils/generate_leads_salesnav.py +303 -0
  46. dhisana/utils/generate_linkedin_connect_message.py +224 -0
  47. dhisana/utils/generate_linkedin_response_message.py +317 -0
  48. dhisana/utils/generate_structured_output_internal.py +462 -0
  49. dhisana/utils/google_custom_search.py +267 -0
  50. dhisana/utils/google_oauth_tools.py +727 -0
  51. dhisana/utils/google_workspace_tools.py +1294 -0
  52. dhisana/utils/hubspot_clearbit.py +96 -0
  53. dhisana/utils/hubspot_crm_tools.py +2440 -0
  54. dhisana/utils/instantly_tools.py +149 -0
  55. dhisana/utils/linkedin_crawler.py +168 -0
  56. dhisana/utils/lusha_tools.py +333 -0
  57. dhisana/utils/mailgun_tools.py +156 -0
  58. dhisana/utils/mailreach_tools.py +123 -0
  59. dhisana/utils/microsoft365_tools.py +455 -0
  60. dhisana/utils/openai_assistant_and_file_utils.py +267 -0
  61. dhisana/utils/openai_helpers.py +977 -0
  62. dhisana/utils/openapi_spec_to_tools.py +45 -0
  63. dhisana/utils/openapi_tool/__init__.py +1 -0
  64. dhisana/utils/openapi_tool/api_models.py +633 -0
  65. dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +271 -0
  66. dhisana/utils/openapi_tool/openapi_tool.py +319 -0
  67. dhisana/utils/parse_linkedin_messages_txt.py +100 -0
  68. dhisana/utils/profile.py +37 -0
  69. dhisana/utils/proxy_curl_tools.py +1226 -0
  70. dhisana/utils/proxycurl_search_leads.py +426 -0
  71. dhisana/utils/python_function_to_tools.py +83 -0
  72. dhisana/utils/research_lead.py +176 -0
  73. dhisana/utils/sales_navigator_crawler.py +1103 -0
  74. dhisana/utils/salesforce_crm_tools.py +477 -0
  75. dhisana/utils/search_router.py +131 -0
  76. dhisana/utils/search_router_jobs.py +51 -0
  77. dhisana/utils/sendgrid_tools.py +162 -0
  78. dhisana/utils/serarch_router_local_business.py +75 -0
  79. dhisana/utils/serpapi_additional_tools.py +290 -0
  80. dhisana/utils/serpapi_google_jobs.py +117 -0
  81. dhisana/utils/serpapi_google_search.py +188 -0
  82. dhisana/utils/serpapi_local_business_search.py +129 -0
  83. dhisana/utils/serpapi_search_tools.py +852 -0
  84. dhisana/utils/serperdev_google_jobs.py +125 -0
  85. dhisana/utils/serperdev_local_business.py +154 -0
  86. dhisana/utils/serperdev_search.py +233 -0
  87. dhisana/utils/smtp_email_tools.py +582 -0
  88. dhisana/utils/test_connect.py +2087 -0
  89. dhisana/utils/trasform_json.py +173 -0
  90. dhisana/utils/web_download_parse_tools.py +189 -0
  91. dhisana/utils/workflow_code_model.py +5 -0
  92. dhisana/utils/zoominfo_tools.py +357 -0
  93. dhisana/workflow/__init__.py +1 -0
  94. dhisana/workflow/agent.py +18 -0
  95. dhisana/workflow/flow.py +44 -0
  96. dhisana/workflow/task.py +43 -0
  97. dhisana/workflow/test.py +90 -0
  98. dhisana-0.0.1.dev243.dist-info/METADATA +43 -0
  99. dhisana-0.0.1.dev243.dist-info/RECORD +102 -0
  100. dhisana-0.0.1.dev243.dist-info/WHEEL +5 -0
  101. dhisana-0.0.1.dev243.dist-info/entry_points.txt +2 -0
  102. 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