Skip to content

Commit e853fb3

Browse files
authored
feat: Add HubSpot CRM integration components (#26)
- Add contact creator component - Add context gather component - Add property update component - Update contact search and note creator
1 parent a10ab45 commit e853fb3

File tree

6 files changed

+842
-15
lines changed

6 files changed

+842
-15
lines changed

langbuilder/src/backend/base/langbuilder/components/hubspot/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
from .hubspot_company_fetcher import HubSpotCompanyFetcher
55
from .hubspot_contact_updater import HubSpotContactUpdater
66
from .hubspot_contact_search import HubSpotContactSearchComponent
7+
from .hubspot_context_gather import HubSpotContextGatherComponent
8+
from .hubspot_property_update import HubSpotPropertyUpdateComponent
9+
from .hubspot_contact_creator import HubSpotContactCreatorComponent
710

811
__all__ = [
912
"HubSpotNoteCreator",
@@ -12,4 +15,7 @@
1215
"HubSpotCompanyFetcher",
1316
"HubSpotContactUpdater",
1417
"HubSpotContactSearchComponent",
18+
"HubSpotContextGatherComponent",
19+
"HubSpotPropertyUpdateComponent",
20+
"HubSpotContactCreatorComponent",
1521
]
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
"""
2+
HubSpot Contact Creator - LangBuilder Custom Component
3+
4+
Creates new contacts in HubSpot CRM with company association.
5+
Used in ICP Validator FALSE path to create new buyer contacts
6+
discovered via Apollo search.
7+
8+
Author: CloudGeometry
9+
Project: Carter's Agents - ICP Validator
10+
"""
11+
12+
from langbuilder.custom import Component
13+
from langbuilder.io import HandleInput, SecretStrInput, StrInput, Output
14+
from langbuilder.schema import Data
15+
# ValueError not used
16+
from loguru import logger
17+
import httpx
18+
19+
20+
class HubSpotContactCreatorComponent(Component):
21+
"""
22+
Create new contacts in HubSpot CRM with company association.
23+
24+
This component creates a new contact from Apollo search results
25+
and associates it with an existing company.
26+
27+
API Calls:
28+
- POST /crm/v3/objects/contacts (create contact with association)
29+
30+
Required scope: crm.objects.contacts.write
31+
"""
32+
33+
display_name = "HubSpot Contact Creator"
34+
description = "Creates new contacts in HubSpot CRM from Apollo search results"
35+
icon = "user-plus"
36+
name = "HubSpotContactCreator"
37+
38+
inputs = [
39+
HandleInput(
40+
name="contact_data",
41+
display_name="Contact Data",
42+
input_types=["Data"],
43+
required=True,
44+
info="Contact data from Apollo search (or any Data with contact fields)",
45+
),
46+
HandleInput(
47+
name="company_input",
48+
display_name="Company Input",
49+
input_types=["Data"],
50+
required=False,
51+
info="Data containing company_id for association (optional)",
52+
),
53+
SecretStrInput(
54+
name="hubspot_api_key",
55+
display_name="HubSpot API Key",
56+
required=True,
57+
info="HubSpot Private App API key with crm.objects.contacts.write scope",
58+
),
59+
StrInput(
60+
name="static_company_id",
61+
display_name="Static Company ID",
62+
required=False,
63+
info="HubSpot company ID to associate (overrides input if provided)",
64+
),
65+
StrInput(
66+
name="lead_source",
67+
display_name="Lead Source",
68+
value="Apollo - ICP Validator",
69+
required=False,
70+
info="Value for lead source property",
71+
),
72+
StrInput(
73+
name="base_url",
74+
display_name="Base URL",
75+
required=False,
76+
value="https://api.hubapi.com",
77+
advanced=True,
78+
info="HubSpot API base URL",
79+
),
80+
]
81+
82+
outputs = [
83+
Output(
84+
name="result",
85+
display_name="Result",
86+
method="create_contact",
87+
),
88+
]
89+
90+
def _extract_contact_info(self) -> dict:
91+
"""Extract contact information from input data."""
92+
input_data = self.contact_data
93+
contact_info = {}
94+
95+
if hasattr(input_data, "data"):
96+
data = input_data.data
97+
if isinstance(data, dict):
98+
# Handle Apollo best_match format
99+
if "best_match" in data and data["best_match"]:
100+
person = data["best_match"]
101+
else:
102+
# Try direct person data or first in people list
103+
person = data.get("person") or data
104+
if "people" in data and data["people"]:
105+
person = data["people"][0]
106+
107+
# Extract fields with Apollo → HubSpot mapping
108+
contact_info = {
109+
"firstname": person.get("first_name") or person.get("firstname", ""),
110+
"lastname": person.get("last_name") or person.get("lastname", ""),
111+
"email": person.get("email", ""),
112+
"jobtitle": person.get("title") or person.get("jobtitle", ""),
113+
"city": person.get("city", ""),
114+
"state": person.get("state", ""),
115+
"country": person.get("country", ""),
116+
}
117+
118+
# Add LinkedIn if available
119+
linkedin = person.get("linkedin_url")
120+
if linkedin:
121+
contact_info["hs_linkedin_url"] = linkedin
122+
123+
# Add lead source
124+
if self.lead_source:
125+
contact_info["leadsource"] = self.lead_source
126+
127+
return contact_info
128+
129+
def _extract_company_id(self) -> str | None:
130+
"""Extract company ID for association."""
131+
# Static company ID takes precedence
132+
if self.static_company_id:
133+
return self.static_company_id
134+
135+
# Try company_input
136+
if self.company_input:
137+
company_data = self.company_input
138+
if hasattr(company_data, "data"):
139+
data = company_data.data
140+
if isinstance(data, dict):
141+
for key in ["company_id", "companyId", "id"]:
142+
if key in data:
143+
return str(data[key])
144+
# Check nested company object
145+
if "company" in data and isinstance(data["company"], dict):
146+
company = data["company"]
147+
for key in ["id", "company_id"]:
148+
if key in company:
149+
return str(company[key])
150+
151+
return None
152+
153+
async def create_contact(self) -> Data:
154+
"""
155+
Create a new contact in HubSpot with company association.
156+
157+
Returns:
158+
Data object with:
159+
- success: Boolean indicating if creation succeeded
160+
- contact_id: New contact's HubSpot ID
161+
- hubspot_url: Direct link to contact in HubSpot
162+
- properties: Properties set on the contact
163+
- company_id: Associated company ID (if any)
164+
"""
165+
if not self.hubspot_api_key:
166+
raise ValueError("HubSpot API key is required")
167+
168+
contact_info = self._extract_contact_info()
169+
company_id = self._extract_company_id()
170+
171+
# Validate required fields
172+
if not contact_info.get("email") and not (
173+
contact_info.get("firstname") and contact_info.get("lastname")
174+
):
175+
raise ValueError(
176+
"Contact requires either email or (firstname + lastname)"
177+
)
178+
179+
base_url = self.base_url or "https://api.hubapi.com"
180+
url = f"{base_url}/crm/v3/objects/contacts"
181+
182+
headers = {
183+
"Authorization": f"Bearer {self.hubspot_api_key}",
184+
"Content-Type": "application/json",
185+
}
186+
187+
# Build payload
188+
payload = {
189+
"properties": {k: v for k, v in contact_info.items() if v}
190+
}
191+
192+
# Add company association if provided
193+
# Association Type ID 279 = Contact to Company (HubSpot-defined)
194+
if company_id:
195+
payload["associations"] = [
196+
{
197+
"to": {"id": company_id},
198+
"types": [
199+
{
200+
"associationCategory": "HUBSPOT_DEFINED",
201+
"associationTypeId": 279,
202+
}
203+
],
204+
}
205+
]
206+
207+
try:
208+
async with httpx.AsyncClient(timeout=30.0) as client:
209+
response = await client.post(url, headers=headers, json=payload)
210+
211+
if response.status_code in [200, 201]:
212+
result = response.json()
213+
new_contact_id = result.get("id")
214+
215+
# Build HubSpot URL
216+
hubspot_url = (
217+
f"https://app.hubspot.com/contacts/contacts/{new_contact_id}"
218+
)
219+
220+
contact_name = (
221+
f"{contact_info.get('firstname', '')} "
222+
f"{contact_info.get('lastname', '')}".strip()
223+
)
224+
self.status = f"Created: {contact_name}"
225+
226+
logger.info(
227+
f"HubSpot contact created: {new_contact_id} "
228+
f"({contact_name}, {contact_info.get('jobtitle', 'No title')})"
229+
)
230+
231+
return Data(
232+
data={
233+
"success": True,
234+
"contact_id": new_contact_id,
235+
"hubspot_url": hubspot_url,
236+
"properties": contact_info,
237+
"company_id": company_id,
238+
"name": contact_name,
239+
"email": contact_info.get("email", ""),
240+
"title": contact_info.get("jobtitle", ""),
241+
}
242+
)
243+
244+
elif response.status_code == 409:
245+
# Contact already exists (duplicate email)
246+
try:
247+
error_data = response.json()
248+
existing_id = error_data.get("message", "").split("ID: ")[-1]
249+
self.status = f"Duplicate - exists: {existing_id}"
250+
251+
return Data(
252+
data={
253+
"success": False,
254+
"error": "Contact already exists",
255+
"existing_contact_id": existing_id.strip(),
256+
"email": contact_info.get("email"),
257+
}
258+
)
259+
except Exception:
260+
pass
261+
262+
self.status = "Duplicate contact"
263+
raise ValueError("Contact already exists in HubSpot")
264+
265+
elif response.status_code == 401:
266+
self.status = "Authentication failed"
267+
raise ValueError("HubSpot authentication failed - check API key")
268+
269+
elif response.status_code == 403:
270+
self.status = "Permission denied"
271+
raise ValueError(
272+
"HubSpot permission denied - ensure crm.objects.contacts.write scope"
273+
)
274+
275+
else:
276+
try:
277+
error_data = response.json()
278+
error_msg = error_data.get("message", response.text)
279+
except Exception:
280+
error_msg = response.text
281+
282+
self.status = f"Error: {response.status_code}"
283+
raise ValueError(
284+
f"HubSpot contact creation failed ({response.status_code}): {error_msg}"
285+
)
286+
287+
except ValueError:
288+
raise
289+
except Exception as e:
290+
logger.opt(exception=True).error("HubSpot contact creation failed")
291+
self.status = f"Error: {str(e)}"
292+
raise ValueError(f"HubSpot contact creation failed: {str(e)}") from e

langbuilder/src/backend/base/langbuilder/components/hubspot/hubspot_contact_search.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class HubSpotContactSearchComponent(Component):
5959
display_name="Search Query",
6060
required=False,
6161
info="Name to search for (overrides parsed_data.contact_name if provided)",
62+
tool_mode=True, # Agent can set this when used as tool
6263
),
6364
IntInput(
6465
name="limit",
@@ -99,18 +100,36 @@ async def search(self) -> Data:
99100
if not self.hubspot_api_key:
100101
raise ToolException("HubSpot API key is required")
101102

102-
# Get query from search_query or from parsed_data.contact_name
103+
# Get query from search_query or from parsed_data.contact_name/text
103104
query = ""
105+
debug_info = []
106+
104107
if self.search_query:
105108
query = str(self.search_query).strip()
109+
debug_info.append(f"search_query='{query}'")
106110
elif self.parsed_data:
107-
# Extract contact_name from parsed_data
108-
data = self.parsed_data.data if hasattr(self.parsed_data, 'data') else self.parsed_data
111+
# Extract search query from parsed_data
112+
# Handle list inputs (from DataConditionalRouter which returns lists)
113+
pd = self.parsed_data
114+
debug_info.append(f"parsed_data type={type(pd).__name__}")
115+
if isinstance(pd, list) and len(pd) > 0:
116+
debug_info.append(f"list len={len(pd)}")
117+
pd = pd[0] # Take first item from list
118+
data = pd.data if hasattr(pd, 'data') else pd
119+
debug_info.append(f"data type={type(data).__name__}")
109120
if isinstance(data, dict):
110-
query = data.get("contact_name", "") or ""
121+
debug_info.append(f"dict keys={list(data.keys())[:5]}")
122+
# Try search_query first (from Router), then contact_name, then text
123+
query = data.get("search_query", "") or data.get("contact_name", "") or data.get("text", "") or ""
124+
debug_info.append(f"query='{query}'")
125+
else:
126+
debug_info.append(f"data not dict: {str(data)[:50]}")
127+
else:
128+
debug_info.append("NO parsed_data and NO search_query")
111129

112130
if not query:
113-
raise ToolException("Search query is required (provide search_query or parsed_data with contact_name)")
131+
debug_str = "; ".join(debug_info)
132+
raise ToolException(f"Search query is required. DEBUG: {debug_str}")
114133

115134
base_url = self.base_url or "https://api.hubapi.com"
116135
limit = self.limit or 5

0 commit comments

Comments
 (0)