diff --git a/camel/toolkits/__init__.py b/camel/toolkits/__init__.py index f29ab67d40..263f4bca71 100644 --- a/camel/toolkits/__init__.py +++ b/camel/toolkits/__init__.py @@ -94,6 +94,7 @@ from .notion_mcp_toolkit import NotionMCPToolkit from .vertex_ai_veo_toolkit import VertexAIVeoToolkit from .minimax_mcp_toolkit import MinimaxMCPToolkit +from .zoominfo_toolkit import ZoomInfoToolkit __all__ = [ 'BaseToolkit', @@ -178,4 +179,5 @@ 'NotionMCPToolkit', 'VertexAIVeoToolkit', 'MinimaxMCPToolkit', + "ZoomInfoToolkit", ] diff --git a/camel/toolkits/zoominfo_toolkit.py b/camel/toolkits/zoominfo_toolkit.py new file mode 100644 index 0000000000..9148293c07 --- /dev/null +++ b/camel/toolkits/zoominfo_toolkit.py @@ -0,0 +1,312 @@ +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= + +import os +import time +import json +import requests +import logging +from typing import Any, Dict, List, Optional, Literal +from camel.toolkits.base import BaseToolkit +from camel.toolkits import FunctionTool +from camel.utils import MCPServer, api_keys_required, retry_on_error + + +# Global variables for token management +_zoominfo_access_token = None +_zoominfo_token_expires_at = 0 + +# Setup logger +logger = logging.getLogger(__name__) + + +def _get_zoominfo_token() -> str: + r"""Get or refresh ZoomInfo JWT token.""" + global _zoominfo_access_token, _zoominfo_token_expires_at + + # Check if current token is still valid + if _zoominfo_access_token and time.time() < _zoominfo_token_expires_at: + return _zoominfo_access_token + + # Get credentials from environment + username = os.getenv("ZOOMINFO_USERNAME") + password = os.getenv("ZOOMINFO_PASSWORD") + client_id = os.getenv("ZOOMINFO_CLIENT_ID") + private_key = os.getenv("ZOOMINFO_PRIVATE_KEY") + + if not username: + raise ValueError("ZoomInfo credentials missing. Please set ZOOMINFO_USERNAME environment variable.") + + # Try PKI authentication first if available + if client_id and private_key: + try: + import jwt + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.serialization import load_pem_private_key + + # Load private key + private_key_obj = load_pem_private_key( + private_key.encode(), + password=None, + backend=default_backend() + ) + + # Create JWT token + current_time = int(time.time()) + payload = { + "iss": client_id, + "exp": current_time + 3600, # 1 hour expiration + "iat": current_time, + "sub": username + } + + token = jwt.encode( + payload, + private_key_obj, + algorithm="RS256", + headers={"kid": client_id} + ) + + _zoominfo_access_token = token + _zoominfo_token_expires_at = current_time + 3500 # Refresh 5 minutes before expiry + return token + + except ImportError: + logger.warning("PKI authentication requires 'pyjwt' and 'cryptography' packages. Falling back to username/password.") + except Exception as e: + logger.warning(f"PKI authentication failed: {e}. Falling back to username/password.") + + # Fallback to username/password authentication + if not password: + raise ValueError("ZoomInfo credentials missing. Please set ZOOMINFO_PASSWORD environment variable.") + + try: + import zi_api_auth_client + token = zi_api_auth_client.user_name_pwd_authentication(username, password) + _zoominfo_access_token = token + _zoominfo_token_expires_at = time.time() + 3500 # Refresh 5 minutes before expiry + return token + except ImportError: + raise ValueError("ZoomInfo authentication requires 'zi_api_auth_client' package. Please install it with: pip install zi_api_auth_client") + + +def _make_zoominfo_request( + method: str, + endpoint: str, + headers: Optional[Dict[str, str]] = None, + json_data: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + r"""Make a request to ZoomInfo API with proper error handling.""" + base_url = "https://api.zoominfo.com" + url = f"{base_url}{endpoint}" + + # Get authentication token + token = _get_zoominfo_token() + + # Prepare headers + request_headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + if headers: + request_headers.update(headers) + + try: + response = requests.request( + method=method, + url=url, + headers=request_headers, + json=json_data, + params=params, + timeout=30, + ) + + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + raise Exception(f"ZoomInfo API request failed: {e}") + + +@MCPServer() +class ZoomInfoToolkit(BaseToolkit): + r"""ZoomInfo API toolkit for B2B data intelligence and contact/company search.""" + + def __init__(self, timeout: Optional[float] = None): + super().__init__(timeout=timeout) + # Validate credentials on initialization + _get_zoominfo_token() + + @api_keys_required([ + (None, "ZOOMINFO_USERNAME"), + (None, "ZOOMINFO_PASSWORD"), + ]) + # Search for companies by various criteria + def zoominfo_search_companies( + self, + company_name: str = "", + company_website: str = "", + industry: str = "", + rpp: int = 10, + page: int = 1, + sort_by: Literal["name", "employeeCount", "revenue"] = "name", + sort_order: Literal["asc", "desc"] = "asc", + **kwargs + ) -> Dict[str, Any]: + r"""Search for companies using ZoomInfo API. + + Args: + company_name (str): Company name to search for + company_website (str): Company website to search for + industry (str): Industry to filter by + rpp (int): Results per page (default: 10) + page (int): Page number (default: 1) + sort_by (str): Sort field - "name", "employeeCount", or "revenue" + sort_order (str): Sort order - "asc" or "desc" + **kwargs: Additional search parameters + + Returns: + Dict[str, Any]: Search results with company information + """ + params = { + "rpp": rpp, + "page": page, + "sortBy": sort_by, + "sortOrder": sort_order, + } + + # Add optional parameters + if company_name: + params["companyName"] = company_name + if company_website: + params["companyWebsite"] = company_website + if industry: + params["companyDescription"] = industry + + # Add any additional parameters + params.update(kwargs) + + return _make_zoominfo_request( + method="POST", + endpoint="/search/company", + json_data=params, + ) + + @api_keys_required([ + (None, "ZOOMINFO_USERNAME"), + (None, "ZOOMINFO_PASSWORD"), + ]) + # Search for contacts by company, title, and other criteria + def zoominfo_search_contacts( + self, + company_name: str = "", + job_title: str = "", + management_level: str = "", + email_address: str = "", + rpp: int = 10, + page: int = 1, + sort_by: Literal[ + "contactAccuracyScore", "lastName", "companyName", + "hierarchy", "sourceCount", "lastMentioned", "relevance" + ] = "contactAccuracyScore", + sort_order: Literal["asc", "desc"] = "desc", + **kwargs + ) -> Dict[str, Any]: + r"""Search for contacts using ZoomInfo API. + + Args: + company_name (str): Company name to search in + job_title (str): Job title to search for + management_level (str): Management level to filter by + email_address (str): Email address to search for + rpp (int): Results per page (default: 10) + page (int): Page number (default: 1) + sort_by (str): Sort field for contacts + sort_order (str): Sort order - "asc" or "desc" + **kwargs: Additional search parameters + + Returns: + Dict[str, Any]: Search results with contact information + """ + params = { + "rpp": rpp, + "page": page, + "sortBy": sort_by, + "sortOrder": sort_order, + } + + # Add optional parameters + if company_name: + params["companyName"] = company_name + if job_title: + params["jobTitle"] = job_title + if management_level: + params["managementLevel"] = management_level + if email_address: + params["emailAddress"] = email_address + + # Add any additional parameters + params.update(kwargs) + + return _make_zoominfo_request( + method="POST", + endpoint="/search/contact", + json_data=params, + ) + + @api_keys_required([ + (None, "ZOOMINFO_USERNAME"), + (None, "ZOOMINFO_PASSWORD"), + ]) + # Enrich contact information with additional data + def zoominfo_enrich_contact( + self, + match_person_input: List[Dict[str, Any]], + output_fields: List[str], + **kwargs + ) -> Dict[str, Any]: + r"""Enrich contact information using ZoomInfo API. + + Args: + match_person_input (List[Dict]): List of contact inputs to match + output_fields (List[str]): List of fields to return in output + **kwargs: Additional enrichment parameters + + Returns: + Dict[str, Any]: Enriched contact information + """ + params = { + "matchPersonInput": match_person_input, + "outputFields": output_fields, + } + + # Add any additional parameters + params.update(kwargs) + + return _make_zoominfo_request( + method="POST", + endpoint="/enrich/contact", + json_data=params, + ) + + def get_tools(self) -> List[FunctionTool]: + r"""Returns toolkit functions as tools.""" + return [ + FunctionTool(self.zoominfo_search_companies), + FunctionTool(self.zoominfo_search_contacts), + FunctionTool(self.zoominfo_enrich_contact), + ] diff --git a/examples/toolkits/zoominfo_toolkit.py b/examples/toolkits/zoominfo_toolkit.py new file mode 100644 index 0000000000..0166659d41 --- /dev/null +++ b/examples/toolkits/zoominfo_toolkit.py @@ -0,0 +1,91 @@ +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= +from camel.agents import ChatAgent +from camel.models import ModelFactory +from camel.toolkits import ZoomInfoToolkit +from camel.types import ModelPlatformType, ModelType + +def main(): + # Initialize ZoomInfo toolkit + zoominfo_toolkit = ZoomInfoToolkit() + + # Create model + model = ModelFactory.create( + model_platform=ModelPlatformType.DEFAULT, + model_type=ModelType.DEFAULT, + ) + + # Create agent with ZoomInfo toolkit + agent = ChatAgent( + system_message="You are a B2B intelligence assistant. Help with company research, contact discovery, and data enrichment using ZoomInfo.", + model=model, + tools=zoominfo_toolkit.get_tools(), + ) + + # Example 1: Search for technology companies + response = agent.step( + "Search for technology companies in California with more than 100 employees. Show me the top 10 results sorted by employee count." + ) + print("Company Search Response:", response.msg.content) + print("Tool calls:") + for i, tool_call in enumerate(response.info['tool_calls'], 1): + print(f" {i}. {tool_call.tool_name}({tool_call.args})") + print() + + # Example 2: Search for software engineers at specific companies + response = agent.step( + "Find software engineers at Apple Inc and Microsoft. Sort by contact accuracy score in descending order." + ) + print("Contact Search Response:", response.msg.content) + print("Tool calls:") + for i, tool_call in enumerate(response.info['tool_calls'], 1): + print(f" {i}. {tool_call.tool_name}({tool_call.args})") + print() + + # Example 3: Enrich contact information + response = agent.step( + "Enrich contact information for john.doe@example.com and jane.smith@techcompany.com. " + "Return their first name, last name, email, job title, and company information." + ) + print("Contact Enrichment Response:", response.msg.content) + print("Tool calls:") + for i, tool_call in enumerate(response.info['tool_calls'], 1): + print(f" {i}. {tool_call.tool_name}({tool_call.args})") + print() + + # Example 4: Market research scenario + response = agent.step( + "I'm researching the fintech industry in New York. Find companies in the financial technology sector, " + "then find C-level executives at those companies, and finally enrich their contact information." + ) + print("Market Research Response:", response.msg.content) + print("Tool calls:") + for i, tool_call in enumerate(response.info['tool_calls'], 1): + print(f" {i}. {tool_call.tool_name}({tool_call.args})") + print() + + # Example 5: Sales prospecting scenario + response = agent.step( + "Help me find sales prospects. Look for companies in the SaaS industry with 50-500 employees, " + "then find VPs of Sales or Sales Directors at those companies." + ) + print("Sales Prospecting Response:", response.msg.content) + print("Tool calls:") + for i, tool_call in enumerate(response.info['tool_calls'], 1): + print(f" {i}. {tool_call.tool_name}({tool_call.args})") + print() + + +if __name__ == "__main__": + main() diff --git a/test/toolkits/test_zoominfo_toolkit.py b/test/toolkits/test_zoominfo_toolkit.py new file mode 100644 index 0000000000..03eff51cf8 --- /dev/null +++ b/test/toolkits/test_zoominfo_toolkit.py @@ -0,0 +1,248 @@ +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= + +from unittest.mock import MagicMock, patch + +import pytest +import sys +import os + +# Add the project root to Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from camel.toolkits.zoominfo_toolkit import ( + ZoomInfoToolkit, + _get_zoominfo_token, + _make_zoominfo_request, +) + + +@pytest.fixture(autouse=True) +def set_env_vars(monkeypatch): + """Set up environment variables for testing.""" + monkeypatch.setenv("ZOOMINFO_USERNAME", "test_user") + monkeypatch.setenv("ZOOMINFO_PASSWORD", "test_pass") + monkeypatch.setenv("ZOOMINFO_CLIENT_ID", "test_client_id") + monkeypatch.setenv("ZOOMINFO_PRIVATE_KEY", "-----BEGIN PRIVATE KEY-----\ntest_key\n-----END PRIVATE KEY-----") + + +def test_toolkit_init(): + """Test toolkit initialization.""" + with patch('camel.toolkits.zoominfo_toolkit._get_zoominfo_token') as mock_token: + mock_token.return_value = "test_token" + toolkit = ZoomInfoToolkit() + assert toolkit is not None + + +def test_get_tools(): + """Test getting available tools.""" + with patch('camel.toolkits.zoominfo_toolkit._get_zoominfo_token') as mock_token: + mock_token.return_value = "test_token" + toolkit = ZoomInfoToolkit() + tools = toolkit.get_tools() + assert len(tools) == 3 + tool_names = [tool.func.__name__ for tool in tools] + assert "zoominfo_search_companies" in tool_names + assert "zoominfo_search_contacts" in tool_names + assert "zoominfo_enrich_contact" in tool_names + + +@patch('camel.toolkits.zoominfo_toolkit.requests.request') +def test_search_companies(mock_request): + """Test company search functionality.""" + # Mock successful API response + mock_response = MagicMock() + mock_response.json.return_value = { + "maxResults": 10, + "totalResults": 150, + "currentPage": 1, + "data": [ + {"id": 12345, "name": "Test Company"}, + {"id": 67890, "name": "Another Company"} + ] + } + mock_response.raise_for_status.return_value = None + mock_request.return_value = mock_response + + with patch('camel.toolkits.zoominfo_toolkit._get_zoominfo_token') as mock_token: + mock_token.return_value = "test_token" + toolkit = ZoomInfoToolkit() + + result = toolkit.zoominfo_search_companies( + company_name="Test Company", + rpp=10, + page=1 + ) + + assert "data" in result + assert len(result["data"]) == 2 + assert result["data"][0]["name"] == "Test Company" + + +@patch('camel.toolkits.zoominfo_toolkit.requests.request') +def test_search_contacts(mock_request): + """Test contact search functionality.""" + # Mock successful API response + mock_response = MagicMock() + mock_response.json.return_value = { + "maxResults": 10, + "totalResults": 50, + "currentPage": 1, + "data": [ + { + "id": 12345, + "firstName": "John", + "lastName": "Doe", + "jobTitle": "Software Engineer", + "contactAccuracyScore": 95, + "company": {"id": 67890, "name": "Test Company"} + } + ] + } + mock_response.raise_for_status.return_value = None + mock_request.return_value = mock_response + + with patch('camel.toolkits.zoominfo_toolkit._get_zoominfo_token') as mock_token: + mock_token.return_value = "test_token" + toolkit = ZoomInfoToolkit() + + result = toolkit.zoominfo_search_contacts( + company_name="Test Company", + job_title="Software Engineer", + rpp=10, + page=1 + ) + + assert "data" in result + assert len(result["data"]) == 1 + assert result["data"][0]["firstName"] == "John" + assert result["data"][0]["contactAccuracyScore"] == 95 + + +@patch('camel.toolkits.zoominfo_toolkit.requests.request') +def test_enrich_contact(mock_request): + """Test contact enrichment functionality.""" + # Mock successful API response + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "data": { + "outputFields": ["id", "firstName", "lastName", "email"], + "result": [ + { + "input": {"emailAddress": "john@example.com"}, + "data": [ + { + "id": 12345, + "firstName": "John", + "lastName": "Doe", + "email": "john.doe@company.com" + } + ], + "matchStatus": "matched" + } + ] + } + } + mock_response.raise_for_status.return_value = None + mock_request.return_value = mock_response + + with patch('camel.toolkits.zoominfo_toolkit._get_zoominfo_token') as mock_token: + mock_token.return_value = "test_token" + toolkit = ZoomInfoToolkit() + + result = toolkit.zoominfo_enrich_contact( + match_person_input=[{"emailAddress": "john@example.com"}], + output_fields=["id", "firstName", "lastName", "email"] + ) + + assert result["success"] is True + assert len(result["data"]["result"]) == 1 + assert result["data"]["result"][0]["matchStatus"] == "matched" + + +def test_get_access_token_password(): + """Test access token retrieval with username/password.""" + # Mock the zi_api_auth_client module to avoid import errors + mock_auth = MagicMock() + mock_auth.user_name_pwd_authentication.return_value = "test_jwt_token" + + # Clear global variables + import camel.toolkits.zoominfo_toolkit + camel.toolkits.zoominfo_toolkit._zoominfo_access_token = None + camel.toolkits.zoominfo_toolkit._zoominfo_token_expires_at = 0 + + # Mock the import to avoid ModuleNotFoundError + with patch.dict('sys.modules', {'zi_api_auth_client': mock_auth}): + token = _get_zoominfo_token() + assert token == "test_jwt_token" + mock_auth.user_name_pwd_authentication.assert_called_once_with("test_user", "test_pass") + + +def test_missing_credentials(monkeypatch): + """Test initialization with missing credentials.""" + monkeypatch.delenv("ZOOMINFO_USERNAME", raising=False) + + with pytest.raises(ValueError, match="ZoomInfo credentials missing"): + # Clear the cached token first + import camel.toolkits.zoominfo_toolkit + camel.toolkits.zoominfo_toolkit._zoominfo_access_token = None + camel.toolkits.zoominfo_toolkit._zoominfo_token_expires_at = 0 + ZoomInfoToolkit() + + +@patch('camel.toolkits.zoominfo_toolkit.requests.request') +def test_api_request_error(mock_request): + """Test API request error handling.""" + mock_request.side_effect = Exception("ZoomInfo API request failed: Connection error") + + with patch('camel.toolkits.zoominfo_toolkit._get_zoominfo_token') as mock_token: + mock_token.return_value = "test_token" + + with pytest.raises(Exception, match="ZoomInfo API request failed"): + _make_zoominfo_request("POST", "/search/company", {"test": "data"}) + + +@patch('camel.toolkits.zoominfo_toolkit.requests.request') +def test_search_companies_with_filters(mock_request): + """Test company search with various filters.""" + mock_response = MagicMock() + mock_response.json.return_value = {"data": []} + mock_response.raise_for_status.return_value = None + mock_request.return_value = mock_response + + with patch('camel.toolkits.zoominfo_toolkit._get_zoominfo_token') as mock_token: + mock_token.return_value = "test_token" + toolkit = ZoomInfoToolkit() + + # Test with all parameters + toolkit.zoominfo_search_companies( + company_name="Test Corp", + company_website="testcorp.com", + industry="Technology", + rpp=20, + page=2, + sort_by="employeeCount", + sort_order="desc" + ) + + # Verify the request was made with correct parameters + call_args = mock_request.call_args + assert call_args[1]["json"]["companyName"] == "Test Corp" + assert call_args[1]["json"]["companyWebsite"] == "testcorp.com" + assert call_args[1]["json"]["companyDescription"] == "Technology" + assert call_args[1]["json"]["rpp"] == 20 + assert call_args[1]["json"]["page"] == 2 + assert call_args[1]["json"]["sortBy"] == "employeeCount" + assert call_args[1]["json"]["sortOrder"] == "desc"