dhisana 0.0.1.dev116__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.
- dhisana/schemas/common.py +10 -1
- dhisana/schemas/sales.py +203 -22
- dhisana/utils/add_mapping.py +0 -2
- dhisana/utils/apollo_tools.py +739 -119
- dhisana/utils/built_with_api_tools.py +4 -2
- dhisana/utils/check_email_validity_tools.py +35 -18
- dhisana/utils/check_for_intent_signal.py +1 -2
- dhisana/utils/check_linkedin_url_validity.py +34 -8
- dhisana/utils/clay_tools.py +3 -2
- dhisana/utils/clean_properties.py +1 -4
- dhisana/utils/compose_salesnav_query.py +0 -1
- dhisana/utils/compose_search_query.py +7 -3
- dhisana/utils/composite_tools.py +0 -1
- dhisana/utils/dataframe_tools.py +2 -2
- dhisana/utils/email_body_utils.py +72 -0
- dhisana/utils/email_provider.py +174 -35
- dhisana/utils/enrich_lead_information.py +183 -53
- dhisana/utils/fetch_openai_config.py +129 -0
- dhisana/utils/field_validators.py +1 -1
- dhisana/utils/g2_tools.py +0 -1
- dhisana/utils/generate_content.py +0 -1
- dhisana/utils/generate_email.py +68 -23
- dhisana/utils/generate_email_response.py +294 -46
- dhisana/utils/generate_flow.py +0 -1
- dhisana/utils/generate_linkedin_connect_message.py +9 -2
- dhisana/utils/generate_linkedin_response_message.py +137 -66
- dhisana/utils/generate_structured_output_internal.py +317 -164
- dhisana/utils/google_custom_search.py +150 -44
- dhisana/utils/google_oauth_tools.py +721 -0
- dhisana/utils/google_workspace_tools.py +278 -54
- dhisana/utils/hubspot_clearbit.py +3 -1
- dhisana/utils/hubspot_crm_tools.py +718 -272
- dhisana/utils/instantly_tools.py +3 -1
- dhisana/utils/lusha_tools.py +10 -7
- dhisana/utils/mailgun_tools.py +150 -0
- dhisana/utils/microsoft365_tools.py +447 -0
- dhisana/utils/openai_assistant_and_file_utils.py +121 -177
- dhisana/utils/openai_helpers.py +8 -6
- dhisana/utils/parse_linkedin_messages_txt.py +1 -3
- dhisana/utils/profile.py +37 -0
- dhisana/utils/proxy_curl_tools.py +377 -76
- dhisana/utils/proxycurl_search_leads.py +426 -0
- dhisana/utils/research_lead.py +3 -3
- dhisana/utils/sales_navigator_crawler.py +1 -6
- dhisana/utils/salesforce_crm_tools.py +323 -50
- dhisana/utils/search_router.py +131 -0
- dhisana/utils/search_router_jobs.py +51 -0
- dhisana/utils/sendgrid_tools.py +126 -91
- dhisana/utils/serarch_router_local_business.py +75 -0
- dhisana/utils/serpapi_additional_tools.py +290 -0
- dhisana/utils/serpapi_google_jobs.py +117 -0
- dhisana/utils/serpapi_google_search.py +188 -0
- dhisana/utils/serpapi_local_business_search.py +129 -0
- dhisana/utils/serpapi_search_tools.py +360 -432
- dhisana/utils/serperdev_google_jobs.py +125 -0
- dhisana/utils/serperdev_local_business.py +154 -0
- dhisana/utils/serperdev_search.py +233 -0
- dhisana/utils/smtp_email_tools.py +178 -18
- dhisana/utils/test_connect.py +1603 -130
- dhisana/utils/trasform_json.py +3 -3
- dhisana/utils/web_download_parse_tools.py +0 -1
- dhisana/utils/zoominfo_tools.py +2 -3
- dhisana/workflow/test.py +1 -1
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/METADATA +1 -1
- dhisana-0.0.1.dev236.dist-info/RECORD +100 -0
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/WHEEL +1 -1
- dhisana-0.0.1.dev116.dist-info/RECORD +0 -83
- {dhisana-0.0.1.dev116.dist-info → dhisana-0.0.1.dev236.dist-info}/entry_points.txt +0 -0
- {dhisana-0.0.1.dev116.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
|
|
4
|
-
from typing import
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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": "
|
|
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","
|
|
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,
|
|
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": "
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
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, ...
|
|
@@ -1836,159 +1872,569 @@ async def lookup_contact_by_email(
|
|
|
1836
1872
|
return transformed
|
|
1837
1873
|
|
|
1838
1874
|
|
|
1839
|
-
|
|
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] = {
|
|
1840
1924
|
"first_name": "firstname",
|
|
1841
1925
|
"last_name": "lastname",
|
|
1842
1926
|
"email": "email",
|
|
1843
1927
|
"phone": "phone",
|
|
1844
1928
|
"job_title": "jobtitle",
|
|
1845
1929
|
"primary_domain_of_organization": "domain",
|
|
1846
|
-
"user_linkedin_url": "
|
|
1847
|
-
|
|
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
|
|
1848
1934
|
}
|
|
1849
1935
|
|
|
1850
|
-
|
|
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
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
1851
1957
|
async def update_crm_contact_record_function(
|
|
1852
1958
|
contact_values: Dict[str, Any],
|
|
1853
1959
|
is_update: bool,
|
|
1854
|
-
# Provide any fields that can be used to find an existing record:
|
|
1855
|
-
# e.g., hubspot_contact_id, email, first_name+last_name+company_domain, user_linkedin_url
|
|
1856
1960
|
hubspot_contact_id: Optional[str] = None,
|
|
1857
1961
|
email: Optional[str] = None,
|
|
1858
1962
|
first_name: Optional[str] = None,
|
|
1859
1963
|
last_name: Optional[str] = None,
|
|
1860
1964
|
company_domain: Optional[str] = None,
|
|
1861
1965
|
user_linkedin_url: Optional[str] = None,
|
|
1862
|
-
tags: Optional[List[str]] = None,
|
|
1863
|
-
|
|
1966
|
+
tags: Optional[List[str]] = None,
|
|
1967
|
+
tag_property: Optional[str] = None, # ← NEW
|
|
1968
|
+
tool_config: Optional[List[Dict]] = None,
|
|
1864
1969
|
) -> Dict[str, Any]:
|
|
1865
1970
|
"""
|
|
1866
|
-
Create or update a HubSpot contact
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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.
|
|
1874
1981
|
"""
|
|
1875
1982
|
|
|
1876
1983
|
HUBSPOT_API_KEY = get_hubspot_access_token(tool_config)
|
|
1877
1984
|
headers = {
|
|
1878
1985
|
"Authorization": f"Bearer {HUBSPOT_API_KEY}",
|
|
1879
|
-
"Content-Type": "application/json"
|
|
1986
|
+
"Content-Type": "application/json",
|
|
1880
1987
|
}
|
|
1881
1988
|
|
|
1882
|
-
# 1) Fetch valid HubSpot contact property names so we don't crash on unknown properties
|
|
1883
|
-
# If you already have a helper function to do this, reuse it.
|
|
1884
|
-
# e.g. `_fetch_all_contact_properties(headers)` -> returns a list of valid property names.
|
|
1885
1989
|
valid_props = await _fetch_all_contact_properties(headers)
|
|
1886
1990
|
|
|
1887
|
-
#
|
|
1888
|
-
|
|
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] = {}
|
|
1889
1997
|
for k, v in contact_values.items():
|
|
1890
1998
|
if v is None:
|
|
1891
1999
|
continue
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
if mapped_name in valid_props:
|
|
1896
|
-
hubspot_props[mapped_name] = v
|
|
2000
|
+
mapped = PROPERTY_MAPPING.get(k)
|
|
2001
|
+
if mapped and mapped in valid_props:
|
|
2002
|
+
incoming_props[mapped] = v
|
|
1897
2003
|
|
|
1898
|
-
#
|
|
2004
|
+
# ─── 1-b) Handle tags ----------------------------------------------------
|
|
1899
2005
|
if tags:
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
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
|
|
1904
2018
|
if is_update:
|
|
1905
|
-
found_contact_id = hubspot_contact_id
|
|
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
|
+
)
|
|
1906
2028
|
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
filters.append({"propertyName": "email", "operator": "EQ", "value": email})
|
|
1912
|
-
if user_linkedin_url and "linkedinbio" in valid_props:
|
|
1913
|
-
filters.append({"propertyName": "linkedinbio", "operator": "EQ", "value": user_linkedin_url})
|
|
1914
|
-
if first_name and last_name and company_domain:
|
|
1915
|
-
filters.append({"propertyName": "firstname", "operator": "EQ", "value": first_name})
|
|
1916
|
-
filters.append({"propertyName": "lastname", "operator": "EQ", "value": last_name})
|
|
1917
|
-
filters.append({"propertyName": "domain", "operator": "EQ", "value": company_domain})
|
|
1918
|
-
|
|
1919
|
-
if filters:
|
|
1920
|
-
search_res = await search_hubspot_objects(
|
|
1921
|
-
object_type="contacts",
|
|
1922
|
-
filters=filters,
|
|
1923
|
-
limit=1,
|
|
1924
|
-
tool_config=tool_config,
|
|
1925
|
-
properties=["email", "firstname", "lastname", "domain"]
|
|
1926
|
-
)
|
|
1927
|
-
results = search_res.get("results", [])
|
|
1928
|
-
if results:
|
|
1929
|
-
found_contact_id = results[0].get("id")
|
|
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", {})
|
|
1930
2033
|
|
|
1931
|
-
|
|
1932
|
-
|
|
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:
|
|
1933
2059
|
update_url = f"https://api.hubapi.com/crm/v3/objects/contacts/{found_contact_id}"
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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
|
+
)
|
|
1947
2079
|
|
|
1948
2080
|
|
|
1949
|
-
|
|
2081
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2082
|
+
# Contact creation helper
|
|
2083
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
2084
|
+
async def _create_new_contact_with_note_or_property(
|
|
1950
2085
|
properties: Dict[str, Any],
|
|
1951
|
-
|
|
2086
|
+
note_html: str,
|
|
2087
|
+
note_plain: str,
|
|
2088
|
+
valid_props: List[str],
|
|
2089
|
+
headers: Dict[str, str],
|
|
2090
|
+
tool_config: Optional[List[Dict]],
|
|
1952
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
|
+
):
|
|
1953
2201
|
"""
|
|
1954
|
-
Create a
|
|
1955
|
-
|
|
2202
|
+
Create a rich-text note and attach it to a contact (associationTypeId 202).
|
|
2203
|
+
`note` must be **HTML**.
|
|
1956
2204
|
"""
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
# For many portals, excluding the marketingContacts block entirely is enough.
|
|
1967
|
-
|
|
1968
|
-
create_payload = {
|
|
1969
|
-
"properties": properties
|
|
1970
|
-
# "marketingContacts": { "marketingContact": False } # <--- If you need to force non-marketing
|
|
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",
|
|
1971
2214
|
}
|
|
1972
2215
|
|
|
1973
|
-
async with aiohttp.ClientSession() as
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
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"]
|
|
1979
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
|
|
1980
2258
|
|
|
1981
|
-
|
|
1982
|
-
|
|
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]:
|
|
1983
2286
|
"""
|
|
1984
|
-
|
|
1985
|
-
|
|
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.
|
|
1986
2299
|
"""
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
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()
|
|
1990
2379
|
if r.status != 200:
|
|
1991
|
-
|
|
1992
|
-
|
|
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:
|
|
1993
2390
|
data = await r.json()
|
|
1994
|
-
|
|
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
|