-
Notifications
You must be signed in to change notification settings - Fork 44.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(blocks): Add Hubspot blocks #8786
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
from typing import Literal | ||
|
||
from autogpt_libs.supabase_integration_credentials_store.types import APIKeyCredentials | ||
from pydantic import SecretStr | ||
|
||
from backend.data.model import CredentialsField, CredentialsMetaInput | ||
|
||
HubSpotCredentials = APIKeyCredentials | ||
HubSpotCredentialsInput = CredentialsMetaInput[ | ||
Literal["hubspot"], | ||
Literal["api_key"], | ||
] | ||
|
||
|
||
def HubSpotCredentialsField() -> HubSpotCredentialsInput: | ||
"""Creates a HubSpot credentials input on a block.""" | ||
return CredentialsField( | ||
provider="hubspot", | ||
supported_credential_types={"api_key"}, | ||
description="The HubSpot integration requires an API Key.", | ||
) | ||
|
||
|
||
TEST_CREDENTIALS = APIKeyCredentials( | ||
id="01234567-89ab-cdef-0123-456789abcdef", | ||
provider="hubspot", | ||
api_key=SecretStr("mock-hubspot-api-key"), | ||
title="Mock HubSpot API key", | ||
expires_at=None, | ||
) | ||
|
||
TEST_CREDENTIALS_INPUT = { | ||
"provider": TEST_CREDENTIALS.provider, | ||
"id": TEST_CREDENTIALS.id, | ||
"type": TEST_CREDENTIALS.type, | ||
"title": TEST_CREDENTIALS.title, | ||
} |
106 changes: 106 additions & 0 deletions
106
autogpt_platform/backend/backend/blocks/hubspot/company.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
from backend.blocks.hubspot._auth import ( | ||
HubSpotCredentials, | ||
HubSpotCredentialsField, | ||
HubSpotCredentialsInput, | ||
) | ||
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema | ||
from backend.data.model import SchemaField | ||
from backend.util.request import requests | ||
|
||
|
||
class HubSpotCompanyBlock(Block): | ||
class Input(BlockSchema): | ||
credentials: HubSpotCredentialsInput = HubSpotCredentialsField() | ||
operation: str = SchemaField( | ||
description="Operation to perform (create, update, get)", default="get" | ||
) | ||
company_data: dict = SchemaField( | ||
description="Company data for create/update operations", default={} | ||
) | ||
domain: str = SchemaField( | ||
description="Company domain for get/update operations", default="" | ||
) | ||
|
||
class Output(BlockSchema): | ||
company: dict = SchemaField(description="Company information") | ||
status: str = SchemaField(description="Operation status") | ||
|
||
def __init__(self): | ||
super().__init__( | ||
id="3ae02219-d540-47cd-9c78-3ad6c7d9820a", | ||
description="Manages HubSpot companies - create, update, and retrieve company information", | ||
categories={BlockCategory.CRM}, | ||
input_schema=HubSpotCompanyBlock.Input, | ||
output_schema=HubSpotCompanyBlock.Output, | ||
) | ||
|
||
def run( | ||
self, input_data: Input, *, credentials: HubSpotCredentials, **kwargs | ||
) -> BlockOutput: | ||
base_url = "https://api.hubapi.com/crm/v3/objects/companies" | ||
headers = { | ||
"Authorization": f"Bearer {credentials.api_key.get_secret_value()}", | ||
"Content-Type": "application/json", | ||
} | ||
|
||
if input_data.operation == "create": | ||
response = requests.post( | ||
base_url, headers=headers, json={"properties": input_data.company_data} | ||
) | ||
result = response.json() | ||
yield "company", result | ||
yield "status", "created" | ||
|
||
elif input_data.operation == "get": | ||
search_url = f"{base_url}/search" | ||
search_data = { | ||
"filterGroups": [ | ||
{ | ||
"filters": [ | ||
{ | ||
"propertyName": "domain", | ||
"operator": "EQ", | ||
"value": input_data.domain, | ||
} | ||
] | ||
} | ||
] | ||
} | ||
response = requests.post(search_url, headers=headers, json=search_data) | ||
result = response.json() | ||
yield "company", result.get("results", [{}])[0] | ||
yield "status", "retrieved" | ||
|
||
elif input_data.operation == "update": | ||
# First get company ID by domain | ||
search_response = requests.post( | ||
f"{base_url}/search", | ||
headers=headers, | ||
json={ | ||
"filterGroups": [ | ||
{ | ||
"filters": [ | ||
{ | ||
"propertyName": "domain", | ||
"operator": "EQ", | ||
"value": input_data.domain, | ||
} | ||
] | ||
} | ||
] | ||
}, | ||
) | ||
company_id = search_response.json().get("results", [{}])[0].get("id") | ||
|
||
if company_id: | ||
response = requests.patch( | ||
f"{base_url}/{company_id}", | ||
headers=headers, | ||
json={"properties": input_data.company_data}, | ||
) | ||
result = response.json() | ||
yield "company", result | ||
yield "status", "updated" | ||
else: | ||
yield "company", {} | ||
yield "status", "company_not_found" |
106 changes: 106 additions & 0 deletions
106
autogpt_platform/backend/backend/blocks/hubspot/contact.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
from backend.blocks.hubspot._auth import ( | ||
HubSpotCredentials, | ||
HubSpotCredentialsField, | ||
HubSpotCredentialsInput, | ||
) | ||
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema | ||
from backend.data.model import SchemaField | ||
from backend.util.request import requests | ||
|
||
|
||
class HubSpotContactBlock(Block): | ||
class Input(BlockSchema): | ||
credentials: HubSpotCredentialsInput = HubSpotCredentialsField() | ||
operation: str = SchemaField( | ||
description="Operation to perform (create, update, get)", default="get" | ||
) | ||
contact_data: dict = SchemaField( | ||
description="Contact data for create/update operations", default={} | ||
) | ||
email: str = SchemaField( | ||
description="Email address for get/update operations", default="" | ||
) | ||
|
||
class Output(BlockSchema): | ||
contact: dict = SchemaField(description="Contact information") | ||
status: str = SchemaField(description="Operation status") | ||
|
||
def __init__(self): | ||
super().__init__( | ||
id="5267326e-c4c1-4016-9f54-4e72ad02f813", | ||
description="Manages HubSpot contacts - create, update, and retrieve contact information", | ||
categories={BlockCategory.CRM}, | ||
input_schema=HubSpotContactBlock.Input, | ||
output_schema=HubSpotContactBlock.Output, | ||
) | ||
|
||
def run( | ||
self, input_data: Input, *, credentials: HubSpotCredentials, **kwargs | ||
) -> BlockOutput: | ||
base_url = "https://api.hubapi.com/crm/v3/objects/contacts" | ||
headers = { | ||
"Authorization": f"Bearer {credentials.api_key.get_secret_value()}", | ||
"Content-Type": "application/json", | ||
} | ||
|
||
if input_data.operation == "create": | ||
response = requests.post( | ||
base_url, headers=headers, json={"properties": input_data.contact_data} | ||
) | ||
result = response.json() | ||
yield "contact", result | ||
yield "status", "created" | ||
|
||
elif input_data.operation == "get": | ||
# Search for contact by email | ||
search_url = f"{base_url}/search" | ||
search_data = { | ||
"filterGroups": [ | ||
{ | ||
"filters": [ | ||
{ | ||
"propertyName": "email", | ||
"operator": "EQ", | ||
"value": input_data.email, | ||
} | ||
] | ||
} | ||
] | ||
} | ||
response = requests.post(search_url, headers=headers, json=search_data) | ||
result = response.json() | ||
yield "contact", result.get("results", [{}])[0] | ||
yield "status", "retrieved" | ||
|
||
elif input_data.operation == "update": | ||
search_response = requests.post( | ||
f"{base_url}/search", | ||
headers=headers, | ||
json={ | ||
"filterGroups": [ | ||
{ | ||
"filters": [ | ||
{ | ||
"propertyName": "email", | ||
"operator": "EQ", | ||
"value": input_data.email, | ||
} | ||
] | ||
} | ||
] | ||
}, | ||
) | ||
contact_id = search_response.json().get("results", [{}])[0].get("id") | ||
|
||
if contact_id: | ||
response = requests.patch( | ||
f"{base_url}/{contact_id}", | ||
headers=headers, | ||
json={"properties": input_data.contact_data}, | ||
) | ||
result = response.json() | ||
yield "contact", result | ||
yield "status", "updated" | ||
else: | ||
yield "contact", {} | ||
yield "status", "contact_not_found" |
121 changes: 121 additions & 0 deletions
121
autogpt_platform/backend/backend/blocks/hubspot/engagement.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
from datetime import datetime, timedelta | ||
|
||
from backend.blocks.hubspot._auth import ( | ||
HubSpotCredentials, | ||
HubSpotCredentialsField, | ||
HubSpotCredentialsInput, | ||
) | ||
from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema | ||
from backend.data.model import SchemaField | ||
from backend.util.request import requests | ||
|
||
|
||
class HubSpotEngagementBlock(Block): | ||
class Input(BlockSchema): | ||
credentials: HubSpotCredentialsInput = HubSpotCredentialsField() | ||
operation: str = SchemaField( | ||
description="Operation to perform (send_email, track_engagement)", | ||
default="send_email", | ||
) | ||
email_data: dict = SchemaField( | ||
description="Email data including recipient, subject, content", | ||
default={}, | ||
) | ||
contact_id: str = SchemaField( | ||
description="Contact ID for engagement tracking", default="" | ||
) | ||
timeframe_days: int = SchemaField( | ||
description="Number of days to look back for engagement", | ||
default=30, | ||
optional=True, | ||
) | ||
|
||
class Output(BlockSchema): | ||
result: dict = SchemaField(description="Operation result") | ||
status: str = SchemaField(description="Operation status") | ||
|
||
def __init__(self): | ||
super().__init__( | ||
id="c6524385-7d87-49d6-a470-248bd29ca765", | ||
description="Manages HubSpot engagements - sends emails and tracks engagement metrics", | ||
categories={BlockCategory.CRM, BlockCategory.COMMUNICATION}, | ||
input_schema=HubSpotEngagementBlock.Input, | ||
output_schema=HubSpotEngagementBlock.Output, | ||
) | ||
|
||
def run( | ||
self, input_data: Input, *, credentials: HubSpotCredentials, **kwargs | ||
) -> BlockOutput: | ||
base_url = "https://api.hubapi.com" | ||
headers = { | ||
"Authorization": f"Bearer {credentials.api_key.get_secret_value()}", | ||
"Content-Type": "application/json", | ||
} | ||
|
||
if input_data.operation == "send_email": | ||
# Using the email send API | ||
email_url = f"{base_url}/crm/v3/objects/emails" | ||
email_data = { | ||
"properties": { | ||
"hs_timestamp": datetime.now().isoformat(), | ||
"hubspot_owner_id": "1", # This should be configurable | ||
"hs_email_direction": "OUTBOUND", | ||
"hs_email_status": "SEND", | ||
"hs_email_subject": input_data.email_data.get("subject"), | ||
"hs_email_text": input_data.email_data.get("content"), | ||
"hs_email_to_email": input_data.email_data.get("recipient"), | ||
} | ||
} | ||
|
||
response = requests.post(email_url, headers=headers, json=email_data) | ||
result = response.json() | ||
yield "result", result | ||
yield "status", "email_sent" | ||
|
||
elif input_data.operation == "track_engagement": | ||
# Get engagement events for the contact | ||
from_date = datetime.now() - timedelta(days=input_data.timeframe_days) | ||
engagement_url = ( | ||
f"{base_url}/crm/v3/objects/contacts/{input_data.contact_id}/engagement" | ||
) | ||
|
||
params = {"limit": 100, "after": from_date.isoformat()} | ||
|
||
response = requests.get(engagement_url, headers=headers, params=params) | ||
engagements = response.json() | ||
|
||
# Process engagement metrics | ||
metrics = { | ||
"email_opens": 0, | ||
"email_clicks": 0, | ||
"email_replies": 0, | ||
"last_engagement": None, | ||
"engagement_score": 0, | ||
} | ||
|
||
for engagement in engagements.get("results", []): | ||
eng_type = engagement.get("properties", {}).get("hs_engagement_type") | ||
if eng_type == "EMAIL": | ||
metrics["email_opens"] += 1 | ||
elif eng_type == "EMAIL_CLICK": | ||
metrics["email_clicks"] += 1 | ||
elif eng_type == "EMAIL_REPLY": | ||
metrics["email_replies"] += 1 | ||
|
||
# Update last engagement time | ||
eng_time = engagement.get("properties", {}).get("hs_timestamp") | ||
if eng_time and ( | ||
not metrics["last_engagement"] | ||
or eng_time > metrics["last_engagement"] | ||
): | ||
metrics["last_engagement"] = eng_time | ||
|
||
# Calculate simple engagement score | ||
metrics["engagement_score"] = ( | ||
metrics["email_opens"] | ||
+ metrics["email_clicks"] * 2 | ||
+ metrics["email_replies"] * 3 | ||
) | ||
|
||
yield "result", metrics | ||
yield "status", "engagement_tracked" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These were alphabetically sorted lists, which helps a lot to avoid merge conflicts. Please keep this in mind in the future. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why aren't these separate blocks?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They don't need to be seperate blocks. I plan to update the user experience though later to show and hide inputs based on what event is being done.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not familiar with Hubspot, but they seem two non-overlapping operations to me. So why are they in one block? The same goes for the
HubspotCompanyBlock
andHubspotContactBlock
, which both support create/update/get operations with non-overlapping implementations.Merging these in one block is a break of pattern regarding how we implement blocks.
It's important to get it right from the start because we currently don't have a way to make functional changes to blocks without breaking graphs where they are in use.