dhisana 0.0.1.dev85__py3-none-any.whl → 0.0.1.dev236__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 (70) hide show
  1. dhisana/schemas/common.py +33 -0
  2. dhisana/schemas/sales.py +224 -23
  3. dhisana/utils/add_mapping.py +72 -63
  4. dhisana/utils/apollo_tools.py +739 -109
  5. dhisana/utils/built_with_api_tools.py +4 -2
  6. dhisana/utils/cache_output_tools.py +23 -23
  7. dhisana/utils/check_email_validity_tools.py +456 -458
  8. dhisana/utils/check_for_intent_signal.py +1 -2
  9. dhisana/utils/check_linkedin_url_validity.py +34 -8
  10. dhisana/utils/clay_tools.py +3 -2
  11. dhisana/utils/clean_properties.py +3 -1
  12. dhisana/utils/compose_salesnav_query.py +0 -1
  13. dhisana/utils/compose_search_query.py +7 -3
  14. dhisana/utils/composite_tools.py +0 -1
  15. dhisana/utils/dataframe_tools.py +2 -2
  16. dhisana/utils/email_body_utils.py +72 -0
  17. dhisana/utils/email_provider.py +375 -0
  18. dhisana/utils/enrich_lead_information.py +585 -85
  19. dhisana/utils/fetch_openai_config.py +129 -0
  20. dhisana/utils/field_validators.py +1 -1
  21. dhisana/utils/g2_tools.py +0 -1
  22. dhisana/utils/generate_content.py +0 -1
  23. dhisana/utils/generate_email.py +69 -16
  24. dhisana/utils/generate_email_response.py +298 -41
  25. dhisana/utils/generate_flow.py +0 -1
  26. dhisana/utils/generate_linkedin_connect_message.py +19 -6
  27. dhisana/utils/generate_linkedin_response_message.py +156 -65
  28. dhisana/utils/generate_structured_output_internal.py +351 -131
  29. dhisana/utils/google_custom_search.py +150 -44
  30. dhisana/utils/google_oauth_tools.py +721 -0
  31. dhisana/utils/google_workspace_tools.py +391 -25
  32. dhisana/utils/hubspot_clearbit.py +3 -1
  33. dhisana/utils/hubspot_crm_tools.py +771 -167
  34. dhisana/utils/instantly_tools.py +3 -1
  35. dhisana/utils/lusha_tools.py +10 -7
  36. dhisana/utils/mailgun_tools.py +150 -0
  37. dhisana/utils/microsoft365_tools.py +447 -0
  38. dhisana/utils/openai_assistant_and_file_utils.py +121 -177
  39. dhisana/utils/openai_helpers.py +19 -16
  40. dhisana/utils/parse_linkedin_messages_txt.py +2 -3
  41. dhisana/utils/profile.py +37 -0
  42. dhisana/utils/proxy_curl_tools.py +507 -206
  43. dhisana/utils/proxycurl_search_leads.py +426 -0
  44. dhisana/utils/research_lead.py +121 -68
  45. dhisana/utils/sales_navigator_crawler.py +1 -6
  46. dhisana/utils/salesforce_crm_tools.py +323 -50
  47. dhisana/utils/search_router.py +131 -0
  48. dhisana/utils/search_router_jobs.py +51 -0
  49. dhisana/utils/sendgrid_tools.py +126 -91
  50. dhisana/utils/serarch_router_local_business.py +75 -0
  51. dhisana/utils/serpapi_additional_tools.py +290 -0
  52. dhisana/utils/serpapi_google_jobs.py +117 -0
  53. dhisana/utils/serpapi_google_search.py +188 -0
  54. dhisana/utils/serpapi_local_business_search.py +129 -0
  55. dhisana/utils/serpapi_search_tools.py +363 -432
  56. dhisana/utils/serperdev_google_jobs.py +125 -0
  57. dhisana/utils/serperdev_local_business.py +154 -0
  58. dhisana/utils/serperdev_search.py +233 -0
  59. dhisana/utils/smtp_email_tools.py +576 -0
  60. dhisana/utils/test_connect.py +1765 -92
  61. dhisana/utils/trasform_json.py +95 -16
  62. dhisana/utils/web_download_parse_tools.py +0 -1
  63. dhisana/utils/zoominfo_tools.py +2 -3
  64. dhisana/workflow/test.py +1 -1
  65. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +5 -2
  66. dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
  67. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
  68. dhisana-0.0.1.dev85.dist-info/RECORD +0 -81
  69. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
  70. {dhisana-0.0.1.dev85.dist-info → dhisana-0.0.1.dev236.dist-info}/top_level.txt +0 -0
@@ -1,16 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ # ─── Standard library ──────────────────────────────────────────────────────────
4
+ import html
1
5
  import json
2
6
  import os
3
- import aiohttp
4
- from typing import Optional, List, Dict, Any
5
- from pydantic import BaseModel
7
+ import time
8
+ from typing import Any, Dict, List, Optional, Union
6
9
  from urllib.parse import urlparse
10
+
11
+ # ─── Third-party packages ──────────────────────────────────────────────────────
12
+ import aiohttp
13
+ from bs4 import BeautifulSoup
7
14
  from fastapi import Query
15
+ from markdown import markdown
16
+ from pydantic import BaseModel
8
17
 
18
+ # ─── Internal / application imports ────────────────────────────────────────────
9
19
  from dhisana.schemas.sales import HUBSPOT_TO_LEAD_MAPPING, HubSpotLeadInformation
10
20
  from dhisana.utils.assistant_tool_tag import assistant_tool
11
21
  from dhisana.utils.clean_properties import cleanup_properties
12
- from typing import Union
13
-
22
+ import logging
14
23
 
15
24
  # --------------------------------------------------------------------
16
25
  # 1. Retrieve HubSpot Access Token
@@ -18,6 +27,9 @@ from typing import Union
18
27
  def get_hubspot_access_token(tool_config: Optional[List[Dict]] = None) -> str:
19
28
  """
20
29
  Retrieves the HubSpot access token from the provided tool configuration.
30
+
31
+ Raises:
32
+ ValueError: If the HubSpot integration has not been configured.
21
33
  """
22
34
  if tool_config:
23
35
  hubspot_config = next(
@@ -29,7 +41,12 @@ def get_hubspot_access_token(tool_config: Optional[List[Dict]] = None) -> str:
29
41
  for item in hubspot_config.get("configuration", [])
30
42
  if item
31
43
  }
32
- HUBSPOT_ACCESS_TOKEN = config_map.get("apiKey")
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")
33
50
  else:
34
51
  HUBSPOT_ACCESS_TOKEN = None
35
52
  else:
@@ -37,7 +54,9 @@ def get_hubspot_access_token(tool_config: Optional[List[Dict]] = None) -> str:
37
54
 
38
55
  HUBSPOT_ACCESS_TOKEN = HUBSPOT_ACCESS_TOKEN or os.getenv("HUBSPOT_API_KEY")
39
56
  if not HUBSPOT_ACCESS_TOKEN:
40
- raise ValueError("HubSpot access token not found in tool_config or environment variable")
57
+ raise ValueError(
58
+ "HubSpot integration is not configured. Please configure the connection to HubSpot in Integrations."
59
+ )
41
60
  return HUBSPOT_ACCESS_TOKEN
42
61
 
43
62
 
@@ -928,96 +947,6 @@ async def update_hubspot_company_info(
928
947
  return updated_data
929
948
 
930
949
 
931
- # --------------------------------------------------------------------
932
- # 4) Create a HubSpot Note for a Customer (via V3)
933
- # - If customer_id is None but email is provided, search for contact ID by email.
934
- # - Then create a "note" object and associate it with the contact.
935
- # --------------------------------------------------------------------
936
- @assistant_tool
937
- async def create_hubspot_note_for_customer(
938
- customer_id: str = None,
939
- email: str = None,
940
- note: str = None,
941
- tool_config: Optional[List[Dict]] = None
942
- ):
943
- """
944
- Create and attach a note (v3) to a customer (contact) in HubSpot using the customer's ID or email.
945
- """
946
- HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
947
- if not (customer_id or email):
948
- raise ValueError("Either 'customer_id' or 'email' must be provided.")
949
- if not note:
950
- raise ValueError("Note content must be provided.")
951
-
952
- headers = {
953
- "Authorization": f"Bearer {HUBSPOT_API_KEY}",
954
- "Content-Type": "application/json"
955
- }
956
-
957
- async with aiohttp.ClientSession() as session:
958
- # ---------------------------------------------------------
959
- # 1) If no contact ID, lookup by email
960
- # ---------------------------------------------------------
961
- if not customer_id:
962
- search_url = "https://api.hubapi.com/crm/v3/objects/contacts/search"
963
- search_payload = {
964
- "filterGroups": [
965
- {
966
- "filters": [
967
- {"propertyName": "email", "operator": "EQ", "value": email}
968
- ]
969
- }
970
- ],
971
- "limit": 1
972
- }
973
- async with session.post(search_url, headers=headers, json=search_payload) as resp:
974
- data = await resp.json()
975
- if resp.status != 200:
976
- raise Exception(f"Error searching contact by email: {resp.status} => {data}")
977
-
978
- results = data.get("results", [])
979
- if not results:
980
- raise Exception(f"No contact found with email '{email}'.")
981
- customer_id = results[0]["id"]
982
-
983
- # ---------------------------------------------------------
984
- # 2) Create a note object with association to the contact
985
- # In V3, "notes" is a standard object type.
986
- # We specify "hs_note_body" in properties, plus associations.
987
- #
988
- # The correct associationTypeId for "note -> contact" can vary,
989
- # but typically "180" or "280." This can be verified via
990
- # GET /crm/v3/associations/Note/Contact (or see docs).
991
- # We'll assume "280" here as an example.
992
- # ---------------------------------------------------------
993
- create_note_url = "https://api.hubapi.com/crm/v3/objects/notes"
994
- note_payload = {
995
- "properties": {
996
- "hs_note_body": note
997
- },
998
- "associations": [
999
- {
1000
- "to": {
1001
- "id": customer_id,
1002
- "type": "contact"
1003
- },
1004
- "types": [
1005
- {
1006
- "associationCategory": "HUBSPOT_DEFINED",
1007
- "associationTypeId": 280
1008
- }
1009
- ]
1010
- }
1011
- ]
1012
- }
1013
- async with session.post(create_note_url, headers=headers, json=note_payload) as resp:
1014
- created_note = await resp.json()
1015
- if resp.status != 201:
1016
- raise Exception(f"Error creating note: {resp.status} => {created_note}")
1017
-
1018
- return created_note
1019
-
1020
-
1021
950
  # --------------------------------------------------------------------
1022
951
  # 5) Get Last N Notes for a Customer (via V3)
1023
952
  # - If customer_id is None but email is provided, find contact ID
@@ -1130,6 +1059,154 @@ async def get_last_n_notes_for_customer(
1130
1059
  return full_notes[:n]
1131
1060
 
1132
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
+
1133
1210
  # --------------------------------------------------------------------
1134
1211
  # 6) Fetch HubSpot Contact Associations (via V3)
1135
1212
  # - e.g., fetch a contact's associated companies, deals, tickets, etc.
@@ -1237,7 +1314,7 @@ async def fetch_hubspot_lead_info(
1237
1314
  })
1238
1315
  if linkedin_url:
1239
1316
  filters.append({
1240
- "propertyName": "linkedin_url", "operator": "EQ", "value": linkedin_url
1317
+ "propertyName": "hs_linkedin_url", "operator": "EQ", "value": linkedin_url
1241
1318
  })
1242
1319
  if phone_number:
1243
1320
  filters.append({
@@ -1254,7 +1331,7 @@ async def fetch_hubspot_lead_info(
1254
1331
  "filters": filters
1255
1332
  }
1256
1333
  ],
1257
- "properties": ["firstname","lastname","email","phone","linkedin_url"],
1334
+ "properties": ["firstname","lastname","email","phone","hs_linkedin_url"],
1258
1335
  "limit": 1
1259
1336
  }
1260
1337
 
@@ -1306,16 +1383,17 @@ async def fetch_hubspot_lead_info(
1306
1383
 
1307
1384
 
1308
1385
  # --------------------------------------------------------------------
1309
- # 2) Fetch HubSpot Contact Info (V3)
1386
+ # 2) Fetch HubSpot Contact Info (V3) with optional custom tags
1310
1387
  # --------------------------------------------------------------------
1311
1388
  @assistant_tool
1312
1389
  async def fetch_hubspot_contact_info(
1313
1390
  hubspot_id: str = None,
1314
1391
  email: str = None,
1315
- tool_config: Optional[List[Dict]] = None
1392
+ tool_config: Optional[List[Dict]] = None,
1393
+ custom_tag_property_name: str = None
1316
1394
  ):
1317
1395
  """
1318
- Fetch contact information from HubSpot, including associated companies, notes, tasks, calls, meetings, etc. (v3).
1396
+ Fetch contact information from HubSpot, including associated companies, notes, tasks, calls, meetings, and optionally custom tags.
1319
1397
  """
1320
1398
  HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
1321
1399
  if not hubspot_id and not email:
@@ -1326,6 +1404,11 @@ async def fetch_hubspot_contact_info(
1326
1404
  "Content-Type": "application/json"
1327
1405
  }
1328
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
+
1329
1412
  contact_info = None
1330
1413
 
1331
1414
  async with aiohttp.ClientSession() as session:
@@ -1334,11 +1417,11 @@ async def fetch_hubspot_contact_info(
1334
1417
  # ---------------------------------------------------------
1335
1418
  if hubspot_id:
1336
1419
  url = f"https://api.hubapi.com/crm/v3/objects/contacts/{hubspot_id}"
1337
- params = {"properties": "email,firstname,lastname,phone"}
1420
+ params = {"properties": ",".join(base_properties)}
1338
1421
  async with session.get(url, headers=headers, params=params) as resp:
1339
- data = await resp.json()
1340
1422
  if resp.status != 200:
1341
- raise Exception(f"Error fetching contact by ID: {resp.status} => {data}")
1423
+ return None
1424
+ data = await resp.json()
1342
1425
  contact_info = data
1343
1426
  else:
1344
1427
  # -----------------------------------------------------
@@ -1353,7 +1436,7 @@ async def fetch_hubspot_contact_info(
1353
1436
  ]
1354
1437
  }
1355
1438
  ],
1356
- "properties": ["email", "firstname", "lastname", "phone"],
1439
+ "properties": base_properties,
1357
1440
  "limit": 1
1358
1441
  }
1359
1442
  async with session.post(search_url, headers=headers, json=payload) as resp:
@@ -1368,72 +1451,25 @@ async def fetch_hubspot_contact_info(
1368
1451
 
1369
1452
  contact_id = contact_info["id"]
1370
1453
 
1371
- # ---------------------------------------------------------
1372
- # 3) Fetch associated companies
1373
- # ---------------------------------------------------------
1374
- assoc_comp_url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}/associations/companies"
1375
- async with session.get(assoc_comp_url, headers=headers) as resp:
1376
- companies_data = await resp.json()
1377
- if resp.status == 200:
1378
- contact_info["companies"] = companies_data.get("results", [])
1379
- else:
1380
- contact_info["companies"] = []
1381
-
1382
- # ---------------------------------------------------------
1383
- # 4) Fetch associated notes
1384
- # ---------------------------------------------------------
1385
- assoc_notes_url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}/associations/notes"
1386
- async with session.get(assoc_notes_url, headers=headers) as resp:
1387
- notes_data = await resp.json()
1388
- if resp.status == 200:
1389
- contact_info["notes"] = notes_data.get("results", [])
1390
- else:
1391
- contact_info["notes"] = []
1392
-
1393
- # ---------------------------------------------------------
1394
- # 5) Fetch associated tasks
1395
- # ---------------------------------------------------------
1396
- assoc_tasks_url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}/associations/tasks"
1397
- async with session.get(assoc_tasks_url, headers=headers) as resp:
1398
- tasks_data = await resp.json()
1399
- if resp.status == 200:
1400
- contact_info["tasks"] = tasks_data.get("results", [])
1401
- else:
1402
- contact_info["tasks"] = []
1403
-
1404
- # ---------------------------------------------------------
1405
- # 6) Fetch associated calls
1406
- # ---------------------------------------------------------
1407
- assoc_calls_url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}/associations/calls"
1408
- async with session.get(assoc_calls_url, headers=headers) as resp:
1409
- calls_data = await resp.json()
1410
- if resp.status == 200:
1411
- contact_info["calls"] = calls_data.get("results", [])
1412
- else:
1413
- contact_info["calls"] = []
1414
-
1415
- # ---------------------------------------------------------
1416
- # 7) Fetch associated meetings
1417
- # ---------------------------------------------------------
1418
- assoc_meetings_url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}/associations/meetings"
1419
- async with session.get(assoc_meetings_url, headers=headers) as resp:
1420
- meetings_data = await resp.json()
1421
- if resp.status == 200:
1422
- contact_info["meetings"] = meetings_data.get("results", [])
1423
- else:
1424
- contact_info["meetings"] = []
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 []
1425
1462
 
1426
- # ---------------------------------------------------------
1427
- # (Optional) for "emails" object or email engagement
1428
- # The v1 email events endpoint was historically used.
1429
- # If you have the new "emails" object, you can do:
1430
- # assoc_emails_url = f"https://api.hubapi.com/crm/v3/objects/contacts/{contact_id}/associations/emails"
1431
- # ... similar approach ...
1432
- # ---------------------------------------------------------
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")
1433
1468
 
1434
1469
  return contact_info
1435
1470
 
1436
1471
 
1472
+
1437
1473
  # --------------------------------------------------------------------
1438
1474
  # 3) Fetch Last N Activities for a Contact (V3 version)
1439
1475
  # "Activities" typically means calls, tasks, notes, meetings, emails, ...
@@ -1834,3 +1870,571 @@ async def lookup_contact_by_email(
1834
1870
  contact_properties = contact_record.get("properties", {})
1835
1871
  transformed = transform_hubspot_contact_to_lead_info(contact_properties)
1836
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