dhisana 0.0.1.dev243__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/__init__.py +1 -0
- dhisana/cli/__init__.py +1 -0
- dhisana/cli/cli.py +20 -0
- dhisana/cli/datasets.py +27 -0
- dhisana/cli/models.py +26 -0
- dhisana/cli/predictions.py +20 -0
- dhisana/schemas/__init__.py +1 -0
- dhisana/schemas/common.py +399 -0
- dhisana/schemas/sales.py +965 -0
- dhisana/ui/__init__.py +1 -0
- dhisana/ui/components.py +472 -0
- dhisana/utils/__init__.py +1 -0
- dhisana/utils/add_mapping.py +352 -0
- dhisana/utils/agent_tools.py +51 -0
- dhisana/utils/apollo_tools.py +1597 -0
- dhisana/utils/assistant_tool_tag.py +4 -0
- dhisana/utils/built_with_api_tools.py +282 -0
- dhisana/utils/cache_output_tools.py +98 -0
- dhisana/utils/cache_output_tools_local.py +78 -0
- dhisana/utils/check_email_validity_tools.py +717 -0
- dhisana/utils/check_for_intent_signal.py +107 -0
- dhisana/utils/check_linkedin_url_validity.py +209 -0
- dhisana/utils/clay_tools.py +43 -0
- dhisana/utils/clean_properties.py +135 -0
- dhisana/utils/company_utils.py +60 -0
- dhisana/utils/compose_salesnav_query.py +259 -0
- dhisana/utils/compose_search_query.py +759 -0
- dhisana/utils/compose_three_step_workflow.py +234 -0
- dhisana/utils/composite_tools.py +137 -0
- dhisana/utils/dataframe_tools.py +237 -0
- dhisana/utils/domain_parser.py +45 -0
- dhisana/utils/email_body_utils.py +72 -0
- dhisana/utils/email_parse_helpers.py +132 -0
- dhisana/utils/email_provider.py +375 -0
- dhisana/utils/enrich_lead_information.py +933 -0
- dhisana/utils/extract_email_content_for_llm.py +101 -0
- dhisana/utils/fetch_openai_config.py +129 -0
- dhisana/utils/field_validators.py +426 -0
- dhisana/utils/g2_tools.py +104 -0
- dhisana/utils/generate_content.py +41 -0
- dhisana/utils/generate_custom_message.py +271 -0
- dhisana/utils/generate_email.py +278 -0
- dhisana/utils/generate_email_response.py +465 -0
- dhisana/utils/generate_flow.py +102 -0
- dhisana/utils/generate_leads_salesnav.py +303 -0
- dhisana/utils/generate_linkedin_connect_message.py +224 -0
- dhisana/utils/generate_linkedin_response_message.py +317 -0
- dhisana/utils/generate_structured_output_internal.py +462 -0
- dhisana/utils/google_custom_search.py +267 -0
- dhisana/utils/google_oauth_tools.py +727 -0
- dhisana/utils/google_workspace_tools.py +1294 -0
- dhisana/utils/hubspot_clearbit.py +96 -0
- dhisana/utils/hubspot_crm_tools.py +2440 -0
- dhisana/utils/instantly_tools.py +149 -0
- dhisana/utils/linkedin_crawler.py +168 -0
- dhisana/utils/lusha_tools.py +333 -0
- dhisana/utils/mailgun_tools.py +156 -0
- dhisana/utils/mailreach_tools.py +123 -0
- dhisana/utils/microsoft365_tools.py +455 -0
- dhisana/utils/openai_assistant_and_file_utils.py +267 -0
- dhisana/utils/openai_helpers.py +977 -0
- dhisana/utils/openapi_spec_to_tools.py +45 -0
- dhisana/utils/openapi_tool/__init__.py +1 -0
- dhisana/utils/openapi_tool/api_models.py +633 -0
- dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +271 -0
- dhisana/utils/openapi_tool/openapi_tool.py +319 -0
- dhisana/utils/parse_linkedin_messages_txt.py +100 -0
- dhisana/utils/profile.py +37 -0
- dhisana/utils/proxy_curl_tools.py +1226 -0
- dhisana/utils/proxycurl_search_leads.py +426 -0
- dhisana/utils/python_function_to_tools.py +83 -0
- dhisana/utils/research_lead.py +176 -0
- dhisana/utils/sales_navigator_crawler.py +1103 -0
- dhisana/utils/salesforce_crm_tools.py +477 -0
- dhisana/utils/search_router.py +131 -0
- dhisana/utils/search_router_jobs.py +51 -0
- dhisana/utils/sendgrid_tools.py +162 -0
- 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 +852 -0
- 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 +582 -0
- dhisana/utils/test_connect.py +2087 -0
- dhisana/utils/trasform_json.py +173 -0
- dhisana/utils/web_download_parse_tools.py +189 -0
- dhisana/utils/workflow_code_model.py +5 -0
- dhisana/utils/zoominfo_tools.py +357 -0
- dhisana/workflow/__init__.py +1 -0
- dhisana/workflow/agent.py +18 -0
- dhisana/workflow/flow.py +44 -0
- dhisana/workflow/task.py +43 -0
- dhisana/workflow/test.py +90 -0
- dhisana-0.0.1.dev243.dist-info/METADATA +43 -0
- dhisana-0.0.1.dev243.dist-info/RECORD +102 -0
- dhisana-0.0.1.dev243.dist-info/WHEEL +5 -0
- dhisana-0.0.1.dev243.dist-info/entry_points.txt +2 -0
- dhisana-0.0.1.dev243.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2087 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Awaitable, Callable, Dict, List, Any, Optional
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from openai import AsyncOpenAI
|
|
11
|
+
except Exception: # pragma: no cover - optional dependency
|
|
12
|
+
AsyncOpenAI = None # type: ignore
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
from google.oauth2 import service_account
|
|
16
|
+
from googleapiclient.discovery import build
|
|
17
|
+
import imaplib
|
|
18
|
+
import aiosmtplib
|
|
19
|
+
from simple_salesforce import Salesforce
|
|
20
|
+
from urllib.parse import urljoin, urlparse
|
|
21
|
+
|
|
22
|
+
from dhisana.utils.clay_tools import push_to_clay_table
|
|
23
|
+
|
|
24
|
+
logging.basicConfig(level=logging.INFO)
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# If FindyMail uses a base URL in your environment, define it here:
|
|
28
|
+
FINDYMAIL_BASE_URL = "https://app.findymail.com/api"
|
|
29
|
+
|
|
30
|
+
###############################################################################
|
|
31
|
+
# HELPER FUNCTIONS
|
|
32
|
+
###############################################################################
|
|
33
|
+
|
|
34
|
+
async def safe_json(response: aiohttp.ClientResponse) -> Any:
|
|
35
|
+
"""
|
|
36
|
+
Safely parse JSON from an aiohttp response.
|
|
37
|
+
Returns None if parsing fails.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
return await response.json()
|
|
41
|
+
except Exception:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
###############################################################################
|
|
45
|
+
# TOOL TEST FUNCTIONS
|
|
46
|
+
###############################################################################
|
|
47
|
+
|
|
48
|
+
async def test_zerobounce(api_key: str) -> Dict[str, Any]:
|
|
49
|
+
url = f"https://api.zerobounce.net/v2/validate?api_key={api_key}&email=contact@dhisana.ai"
|
|
50
|
+
try:
|
|
51
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
52
|
+
async with session.get(url) as response:
|
|
53
|
+
status = response.status
|
|
54
|
+
data = await safe_json(response)
|
|
55
|
+
|
|
56
|
+
if status != 200:
|
|
57
|
+
return {
|
|
58
|
+
"success": False,
|
|
59
|
+
"status_code": status,
|
|
60
|
+
"error_message": f"Non-200 from ZeroBounce: {status}"
|
|
61
|
+
}
|
|
62
|
+
# If the API key is invalid, ZeroBounce might return status=200 but "api_key_invalid"
|
|
63
|
+
if data and data.get("status") == "invalid" and data.get("sub_status") == "api_key_invalid":
|
|
64
|
+
return {
|
|
65
|
+
"success": False,
|
|
66
|
+
"status_code": status,
|
|
67
|
+
"error_message": "ZeroBounce indicates invalid API key"
|
|
68
|
+
}
|
|
69
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.error(f"ZeroBounce test failed: {e}")
|
|
72
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_openai(api_key: str, model_name: str, reasoning_effort: str) -> Dict[str, Any]:
|
|
76
|
+
"""
|
|
77
|
+
Tests OpenAI API key by making a simple chat completion request.
|
|
78
|
+
- If the model name starts with 'o', includes 'reasoning_effort' in the request.
|
|
79
|
+
"""
|
|
80
|
+
url = "https://api.openai.com/v1/chat/completions"
|
|
81
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
82
|
+
|
|
83
|
+
# Base request body
|
|
84
|
+
data = {
|
|
85
|
+
"model": model_name,
|
|
86
|
+
"messages": [{"role": "user", "content": "Hello, world!"}],
|
|
87
|
+
"max_completion_tokens": 5
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Only apply the reasoning parameter if it's an 'o' series model
|
|
91
|
+
if model_name.startswith("o"):
|
|
92
|
+
data["reasoning_effort"] = reasoning_effort
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
96
|
+
async with session.post(url, headers=headers, json=data) as response:
|
|
97
|
+
status = response.status
|
|
98
|
+
resp_data = await safe_json(response)
|
|
99
|
+
|
|
100
|
+
if status != 200:
|
|
101
|
+
err_message = (
|
|
102
|
+
resp_data.get("error", {}).get("message")
|
|
103
|
+
if resp_data and isinstance(resp_data, dict)
|
|
104
|
+
else f"Non-200 from OpenAI: {status}"
|
|
105
|
+
)
|
|
106
|
+
return {
|
|
107
|
+
"success": False,
|
|
108
|
+
"status_code": status,
|
|
109
|
+
"error_message": err_message
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Check if "error" is present in the response
|
|
113
|
+
if resp_data and "error" in resp_data:
|
|
114
|
+
return {
|
|
115
|
+
"success": False,
|
|
116
|
+
"status_code": status,
|
|
117
|
+
"error_message": resp_data["error"].get("message", "OpenAI error returned")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(f"OpenAI test failed: {e}")
|
|
124
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def test_google_workspace(api_key: str, subject: str) -> Dict[str, Any]:
|
|
128
|
+
"""
|
|
129
|
+
Tests Google Workspace by listing Gmail messages using domain-wide delegation.
|
|
130
|
+
Requires subject (email) to impersonate. 'me' then refers to that user mailbox.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
creds_info = json.loads(api_key)
|
|
134
|
+
creds = service_account.Credentials.from_service_account_info(
|
|
135
|
+
creds_info,
|
|
136
|
+
scopes=["https://mail.google.com/"]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Domain-wide delegation requires specifying the email to impersonate
|
|
140
|
+
delegated_creds = creds.with_subject(subject)
|
|
141
|
+
|
|
142
|
+
service = build("gmail", "v1", credentials=delegated_creds)
|
|
143
|
+
|
|
144
|
+
# Execute synchronous call in a background thread to avoid blocking
|
|
145
|
+
def _list_messages():
|
|
146
|
+
return service.users().messages().list(userId="me").execute()
|
|
147
|
+
|
|
148
|
+
response = await asyncio.to_thread(_list_messages)
|
|
149
|
+
|
|
150
|
+
if "messages" in response:
|
|
151
|
+
return {"success": True, "status_code": 200, "error_message": None}
|
|
152
|
+
return {
|
|
153
|
+
"success": False,
|
|
154
|
+
"status_code": 200,
|
|
155
|
+
"error_message": "API responded but no 'messages' key found"
|
|
156
|
+
}
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.error(f"Google Workspace test failed: {e}")
|
|
159
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def test_google_drive(api_key: str, subject: str) -> Dict[str, Any]:
|
|
163
|
+
"""Tests Google Drive API access using domain-wide delegation.
|
|
164
|
+
|
|
165
|
+
Lists files in the impersonated user's Drive to verify the credentials.
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
creds_info = json.loads(api_key)
|
|
169
|
+
creds = service_account.Credentials.from_service_account_info(
|
|
170
|
+
creds_info,
|
|
171
|
+
scopes=["https://www.googleapis.com/auth/drive.metadata.readonly"],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
delegated_creds = creds.with_subject(subject)
|
|
175
|
+
|
|
176
|
+
service = build("drive", "v3", credentials=delegated_creds)
|
|
177
|
+
|
|
178
|
+
def _list_files():
|
|
179
|
+
return service.files().list(pageSize=1).execute()
|
|
180
|
+
|
|
181
|
+
response = await asyncio.to_thread(_list_files)
|
|
182
|
+
|
|
183
|
+
if "files" in response:
|
|
184
|
+
return {"success": True, "status_code": 200, "error_message": None}
|
|
185
|
+
return {
|
|
186
|
+
"success": False,
|
|
187
|
+
"status_code": 200,
|
|
188
|
+
"error_message": "API responded but no 'files' key found",
|
|
189
|
+
}
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error(f"Google Drive test failed: {e}")
|
|
192
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def test_serpapi(api_key: str) -> Dict[str, Any]:
|
|
196
|
+
url = f"https://serpapi.com/search?engine=google&q=hello+world&api_key={api_key}"
|
|
197
|
+
try:
|
|
198
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
199
|
+
async with session.get(url) as response:
|
|
200
|
+
status = response.status
|
|
201
|
+
data = await safe_json(response)
|
|
202
|
+
|
|
203
|
+
if status != 200:
|
|
204
|
+
err_message = data.get("error") if data else f"Non-200 from SERPAPI: {status}"
|
|
205
|
+
return {
|
|
206
|
+
"success": False,
|
|
207
|
+
"status_code": status,
|
|
208
|
+
"error_message": err_message
|
|
209
|
+
}
|
|
210
|
+
# Some SERP API errors might still be 200 but contain an 'error' field
|
|
211
|
+
if data and "error" in data:
|
|
212
|
+
return {
|
|
213
|
+
"success": False,
|
|
214
|
+
"status_code": status,
|
|
215
|
+
"error_message": data["error"]
|
|
216
|
+
}
|
|
217
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.error(f"SERP API test failed: {e}")
|
|
220
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
###############################################################################
|
|
224
|
+
# UPDATED test_serperdev TO MATCH THE search_google_serper USAGE
|
|
225
|
+
###############################################################################
|
|
226
|
+
async def test_serperdev(api_key: str) -> Dict[str, Any]:
|
|
227
|
+
"""
|
|
228
|
+
Tests Serper.dev by sending a POST request to https://google.serper.dev/search
|
|
229
|
+
using similar headers/payload as `search_google_serper`.
|
|
230
|
+
"""
|
|
231
|
+
url = "https://google.serper.dev/search"
|
|
232
|
+
headers = {
|
|
233
|
+
"X-API-KEY": api_key,
|
|
234
|
+
"Content-Type": "application/json"
|
|
235
|
+
}
|
|
236
|
+
payload = {
|
|
237
|
+
"q": "Hello world from SerperDev",
|
|
238
|
+
"gl": "us",
|
|
239
|
+
"hl": "en",
|
|
240
|
+
"autocorrect": True,
|
|
241
|
+
"page": 1,
|
|
242
|
+
"type": "search"
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
247
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
248
|
+
status = response.status
|
|
249
|
+
data = await safe_json(response)
|
|
250
|
+
|
|
251
|
+
if status != 200:
|
|
252
|
+
return {
|
|
253
|
+
"success": False,
|
|
254
|
+
"status_code": status,
|
|
255
|
+
"error_message": f"Non-200 from Serper.dev: {status}"
|
|
256
|
+
}
|
|
257
|
+
# Check if "organic" in the JSON to confirm we got typical search results
|
|
258
|
+
if data and "organic" in data and isinstance(data["organic"], list):
|
|
259
|
+
return {
|
|
260
|
+
"success": True,
|
|
261
|
+
"status_code": status,
|
|
262
|
+
"error_message": None
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
"success": False,
|
|
266
|
+
"status_code": status,
|
|
267
|
+
"error_message": "No 'organic' field found in Serper.dev response"
|
|
268
|
+
}
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.error(f"SerperDev test failed: {e}")
|
|
271
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def test_proxycurl(api_key: str) -> Dict[str, Any]:
|
|
275
|
+
url = "https://enrichlayer.com/api/v2/profile"
|
|
276
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
277
|
+
params = {"linkedin_profile_url": "https://www.linkedin.com/in/satyanadella"}
|
|
278
|
+
try:
|
|
279
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
280
|
+
async with session.get(url, headers=headers, params=params) as response:
|
|
281
|
+
status = response.status
|
|
282
|
+
data = await safe_json(response)
|
|
283
|
+
|
|
284
|
+
if status != 200:
|
|
285
|
+
err_message = None
|
|
286
|
+
if data and isinstance(data, dict):
|
|
287
|
+
err_message = data.get("message") or data.get("detail")
|
|
288
|
+
return {
|
|
289
|
+
"success": False,
|
|
290
|
+
"status_code": status,
|
|
291
|
+
"error_message": err_message or f"Non-200 from Enrich Layer: {status}"
|
|
292
|
+
}
|
|
293
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.error(f"Enrich Layer test failed: {e}")
|
|
296
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def test_exa(api_key: str) -> Dict[str, Any]:
|
|
300
|
+
"""Verify Exa connectivity by issuing a minimal search request."""
|
|
301
|
+
|
|
302
|
+
url = "https://api.exa.ai/search"
|
|
303
|
+
headers = {
|
|
304
|
+
"x-api-key": api_key,
|
|
305
|
+
"Content-Type": "application/json",
|
|
306
|
+
"Accept": "application/json",
|
|
307
|
+
}
|
|
308
|
+
payload = {
|
|
309
|
+
"query": "Dhisana connectivity check",
|
|
310
|
+
"numResults": 1,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
timeout = aiohttp.ClientTimeout(total=10)
|
|
315
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
316
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
317
|
+
status = response.status
|
|
318
|
+
data = await safe_json(response)
|
|
319
|
+
|
|
320
|
+
if status != 200:
|
|
321
|
+
err_message = None
|
|
322
|
+
if isinstance(data, dict):
|
|
323
|
+
err_message = (
|
|
324
|
+
data.get("message")
|
|
325
|
+
or data.get("error")
|
|
326
|
+
or data.get("detail")
|
|
327
|
+
)
|
|
328
|
+
if isinstance(err_message, dict):
|
|
329
|
+
err_message = err_message.get("message") or str(err_message)
|
|
330
|
+
return {
|
|
331
|
+
"success": False,
|
|
332
|
+
"status_code": status,
|
|
333
|
+
"error_message": err_message or f"Non-200 from Exa: {status}",
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if isinstance(data, dict):
|
|
337
|
+
if "error" in data:
|
|
338
|
+
error_value = data["error"]
|
|
339
|
+
if isinstance(error_value, dict):
|
|
340
|
+
error_value = error_value.get("message") or str(error_value)
|
|
341
|
+
return {
|
|
342
|
+
"success": False,
|
|
343
|
+
"status_code": status,
|
|
344
|
+
"error_message": str(error_value),
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
results = data.get("results")
|
|
348
|
+
if isinstance(results, list):
|
|
349
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
"success": False,
|
|
353
|
+
"status_code": status,
|
|
354
|
+
"error_message": "Unexpected response from Exa API.",
|
|
355
|
+
}
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
logger.error(f"Exa test failed: {exc}")
|
|
358
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async def test_apollo(api_key: str) -> Dict[str, Any]:
|
|
362
|
+
organization_domain = 'microsoft.com'
|
|
363
|
+
url = f'https://api.apollo.io/api/v1/organizations/enrich?domain={organization_domain}'
|
|
364
|
+
logger.debug(f"Making GET request to Apollo for domain: {organization_domain}")
|
|
365
|
+
headers = {"X-Api-Key": api_key}
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
369
|
+
async with session.get(url, headers=headers) as response:
|
|
370
|
+
status = response.status
|
|
371
|
+
if status == 200:
|
|
372
|
+
await response.json()
|
|
373
|
+
logger.info("Successfully retrieved organization info from Apollo.")
|
|
374
|
+
return {"success": True, "status_code": status}
|
|
375
|
+
|
|
376
|
+
elif status == 429:
|
|
377
|
+
msg = "Rate limit exceeded"
|
|
378
|
+
logger.warning(msg)
|
|
379
|
+
return {
|
|
380
|
+
"success": False,
|
|
381
|
+
"status_code": status,
|
|
382
|
+
"error_message": msg
|
|
383
|
+
}
|
|
384
|
+
else:
|
|
385
|
+
err_message = None
|
|
386
|
+
if response.content_type == "application/json":
|
|
387
|
+
data = await safe_json(response)
|
|
388
|
+
err_message = data.get("message") if data else None
|
|
389
|
+
return {
|
|
390
|
+
"success": False,
|
|
391
|
+
"status_code": status,
|
|
392
|
+
"error_message": err_message or f"Non-200 from Apollo: {status}"
|
|
393
|
+
}
|
|
394
|
+
except Exception as e:
|
|
395
|
+
logger.error(f"Apollo test failed: {e}")
|
|
396
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
async def test_hubspot(api_key: str) -> Dict[str, Any]:
|
|
400
|
+
url = "https://api.hubapi.com/account-info/v3/details"
|
|
401
|
+
try:
|
|
402
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
403
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
404
|
+
async with session.get(url, headers=headers) as response:
|
|
405
|
+
status = response.status
|
|
406
|
+
data = await safe_json(response)
|
|
407
|
+
|
|
408
|
+
if status != 200:
|
|
409
|
+
err_message = None
|
|
410
|
+
if data and isinstance(data, dict):
|
|
411
|
+
err_message = data.get("message") or data.get("error")
|
|
412
|
+
return {
|
|
413
|
+
"success": False,
|
|
414
|
+
"status_code": status,
|
|
415
|
+
"error_message": err_message or f"Non-200 from HubSpot: {status}"
|
|
416
|
+
}
|
|
417
|
+
if data and "portalId" in data:
|
|
418
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
"success": False,
|
|
422
|
+
"status_code": status,
|
|
423
|
+
"error_message": "Did not find 'portalId' in the response."
|
|
424
|
+
}
|
|
425
|
+
except Exception as e:
|
|
426
|
+
logger.error(f"HubSpot test failed: {e}")
|
|
427
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def test_mailgun(api_key: str, domain: str) -> Dict[str, Any]:
|
|
431
|
+
"""
|
|
432
|
+
Basic Mailgun connectivity check against the domain-specific stats endpoint.
|
|
433
|
+
|
|
434
|
+
Uses BasicAuth("api", api_key) as required by Mailgun. Does not send mail.
|
|
435
|
+
"""
|
|
436
|
+
url = f"https://api.mailgun.net/v3/{domain}/stats/total"
|
|
437
|
+
params = {"event": "accepted", "duration": "1d", "limit": 1}
|
|
438
|
+
try:
|
|
439
|
+
timeout = aiohttp.ClientTimeout(total=10)
|
|
440
|
+
auth = aiohttp.BasicAuth("api", api_key)
|
|
441
|
+
async with aiohttp.ClientSession(timeout=timeout, auth=auth) as session:
|
|
442
|
+
async with session.get(url, params=params) as response:
|
|
443
|
+
status = response.status
|
|
444
|
+
data = await safe_json(response)
|
|
445
|
+
if status != 200:
|
|
446
|
+
msg = None
|
|
447
|
+
if data and isinstance(data, dict):
|
|
448
|
+
msg = data.get("message") or data.get("error")
|
|
449
|
+
return {"success": False, "status_code": status, "error_message": msg or f"Mailgun non-200: {status}"}
|
|
450
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
451
|
+
except Exception as e:
|
|
452
|
+
logger.error(f"Mailgun test failed: {e}")
|
|
453
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
async def test_sendgrid(api_key: str) -> Dict[str, Any]:
|
|
457
|
+
"""
|
|
458
|
+
Basic SendGrid connectivity check via the user account endpoint.
|
|
459
|
+
|
|
460
|
+
SendGrid returns 200 with account details when the API key is valid
|
|
461
|
+
and has sufficient scopes.
|
|
462
|
+
"""
|
|
463
|
+
url = "https://api.sendgrid.com/v3/user/account"
|
|
464
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
465
|
+
try:
|
|
466
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
467
|
+
async with session.get(url, headers=headers) as response:
|
|
468
|
+
status = response.status
|
|
469
|
+
data = await safe_json(response)
|
|
470
|
+
if status != 200:
|
|
471
|
+
msg = None
|
|
472
|
+
if data and isinstance(data, dict):
|
|
473
|
+
# Typical SendGrid error shape: {"errors":[{"message": ...}]}
|
|
474
|
+
errs = data.get("errors")
|
|
475
|
+
if isinstance(errs, list) and errs:
|
|
476
|
+
first = errs[0]
|
|
477
|
+
if isinstance(first, dict):
|
|
478
|
+
msg = first.get("message")
|
|
479
|
+
return {"success": False, "status_code": status, "error_message": msg or f"SendGrid non-200: {status}"}
|
|
480
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
481
|
+
except Exception as e:
|
|
482
|
+
logger.error(f"SendGrid test failed: {e}")
|
|
483
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
async def test_mailreach(api_key: str) -> Dict[str, Any]:
|
|
487
|
+
"""
|
|
488
|
+
Basic MailReach connectivity check using the Ping endpoint.
|
|
489
|
+
|
|
490
|
+
Uses the /v1/ping endpoint to verify API key validity and service availability.
|
|
491
|
+
Reference: https://docs.mailreach.co/reference/getv1ping
|
|
492
|
+
"""
|
|
493
|
+
url = "https://api.mailreach.co/api/v1/ping"
|
|
494
|
+
headers = {"x-api-key": api_key}
|
|
495
|
+
try:
|
|
496
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
497
|
+
async with session.get(url, headers=headers) as response:
|
|
498
|
+
status = response.status
|
|
499
|
+
data = await safe_json(response)
|
|
500
|
+
if status != 200:
|
|
501
|
+
msg = None
|
|
502
|
+
if data and isinstance(data, dict):
|
|
503
|
+
msg = data.get("message") or data.get("error")
|
|
504
|
+
return {"success": False, "status_code": status, "error_message": msg or f"MailReach non-200: {status}"}
|
|
505
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
506
|
+
except Exception as e:
|
|
507
|
+
logger.error(f"MailReach test failed: {e}")
|
|
508
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
async def test_samgov(api_key: str) -> Dict[str, Any]:
|
|
512
|
+
"""Test SAM.gov connectivity by fetching a single opportunity."""
|
|
513
|
+
|
|
514
|
+
url = "https://api.sam.gov/opportunities/v2/search"
|
|
515
|
+
now = datetime.now(timezone.utc)
|
|
516
|
+
posted_to = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
517
|
+
posted_from = (now - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
518
|
+
|
|
519
|
+
params = {
|
|
520
|
+
"limit": 1,
|
|
521
|
+
"offset": 0,
|
|
522
|
+
"keyword": "software",
|
|
523
|
+
"status": "active",
|
|
524
|
+
"includeCount": "true",
|
|
525
|
+
"postedFrom": posted_from,
|
|
526
|
+
"postedTo": posted_to,
|
|
527
|
+
"api_key": api_key,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
532
|
+
|
|
533
|
+
async def perform(request_params: Dict[str, Any]):
|
|
534
|
+
async with session.get(url, params=request_params) as response:
|
|
535
|
+
status = response.status
|
|
536
|
+
body_text = await response.text()
|
|
537
|
+
data: Optional[Dict[str, Any]] = None
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
parsed = json.loads(body_text)
|
|
541
|
+
if isinstance(parsed, dict):
|
|
542
|
+
data = parsed
|
|
543
|
+
except json.JSONDecodeError:
|
|
544
|
+
data = None
|
|
545
|
+
|
|
546
|
+
return status, data, body_text
|
|
547
|
+
|
|
548
|
+
status, data, body_text = await perform(params)
|
|
549
|
+
|
|
550
|
+
def extract_error_message(payload: Optional[Dict[str, Any]], fallback_text: str) -> Optional[str]:
|
|
551
|
+
if not payload:
|
|
552
|
+
return fallback_text[:200] if fallback_text else None
|
|
553
|
+
|
|
554
|
+
errors = payload.get("errors") or payload.get("error")
|
|
555
|
+
if isinstance(errors, list):
|
|
556
|
+
parts = [
|
|
557
|
+
err.get("message") if isinstance(err, dict) else str(err)
|
|
558
|
+
for err in errors
|
|
559
|
+
if err
|
|
560
|
+
]
|
|
561
|
+
return "; ".join(parts) if parts else fallback_text[:200]
|
|
562
|
+
if isinstance(errors, dict):
|
|
563
|
+
return errors.get("message") or str(errors)
|
|
564
|
+
if errors:
|
|
565
|
+
return str(errors)
|
|
566
|
+
|
|
567
|
+
for key in ("message", "errorMessage", "detail", "description"):
|
|
568
|
+
if key in payload and payload[key]:
|
|
569
|
+
return str(payload[key])
|
|
570
|
+
|
|
571
|
+
return fallback_text[:200] if fallback_text else None
|
|
572
|
+
|
|
573
|
+
error_message = extract_error_message(data, body_text)
|
|
574
|
+
|
|
575
|
+
if status == 400 and error_message and "Invalid Date Entered" in error_message:
|
|
576
|
+
fallback_params = dict(params)
|
|
577
|
+
fallback_params["postedFrom"] = (now - timedelta(days=7)).strftime("%m/%d/%Y")
|
|
578
|
+
fallback_params["postedTo"] = now.strftime("%m/%d/%Y")
|
|
579
|
+
status, data, body_text = await perform(fallback_params)
|
|
580
|
+
error_message = extract_error_message(data, body_text)
|
|
581
|
+
|
|
582
|
+
if status != 200:
|
|
583
|
+
return {
|
|
584
|
+
"success": False,
|
|
585
|
+
"status_code": status,
|
|
586
|
+
"error_message": error_message or f"SAM.gov non-200: {status}",
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if not data:
|
|
590
|
+
return {
|
|
591
|
+
"success": False,
|
|
592
|
+
"status_code": status,
|
|
593
|
+
"error_message": "SAM.gov returned invalid JSON response.",
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if data.get("errors"):
|
|
597
|
+
return {
|
|
598
|
+
"success": False,
|
|
599
|
+
"status_code": status,
|
|
600
|
+
"error_message": extract_error_message(data, body_text) or "SAM.gov reported errors.",
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if data.get("opportunitiesData") or data.get("totalRecords") is not None:
|
|
604
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
"success": False,
|
|
608
|
+
"status_code": status,
|
|
609
|
+
"error_message": "Unexpected SAM.gov response payload.",
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
except Exception as e:
|
|
613
|
+
logger.error(f"SAM.gov test failed: {e}")
|
|
614
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
async def test_salesforce(
|
|
618
|
+
username: str,
|
|
619
|
+
password: str,
|
|
620
|
+
security_token: str,
|
|
621
|
+
domain: str,
|
|
622
|
+
client_id: Optional[str] = None,
|
|
623
|
+
client_secret: Optional[str] = None,
|
|
624
|
+
) -> Dict[str, Any]:
|
|
625
|
+
"""Test Salesforce connectivity using provided credentials.
|
|
626
|
+
|
|
627
|
+
If client_id and client_secret are supplied, perform an OAuth2 password
|
|
628
|
+
grant to obtain an access token and execute a simple REST API call. This is
|
|
629
|
+
suitable for production environments. Otherwise, fall back to the
|
|
630
|
+
simple_salesforce login used for testing.
|
|
631
|
+
"""
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
def _connect():
|
|
635
|
+
# OAuth2 password grant flow when client credentials are provided
|
|
636
|
+
if client_id and client_secret:
|
|
637
|
+
token_url = f"https://{domain}.salesforce.com/services/oauth2/token"
|
|
638
|
+
resp = requests.post(
|
|
639
|
+
token_url,
|
|
640
|
+
data={
|
|
641
|
+
"grant_type": "password",
|
|
642
|
+
"client_id": client_id,
|
|
643
|
+
"client_secret": client_secret,
|
|
644
|
+
"username": username,
|
|
645
|
+
"password": f"{password}{security_token}",
|
|
646
|
+
},
|
|
647
|
+
timeout=10,
|
|
648
|
+
)
|
|
649
|
+
resp.raise_for_status()
|
|
650
|
+
data = resp.json()
|
|
651
|
+
access_token = data.get("access_token")
|
|
652
|
+
instance_url = data.get("instance_url")
|
|
653
|
+
if not access_token or not instance_url:
|
|
654
|
+
raise ValueError("Invalid response from Salesforce OAuth2 token endpoint")
|
|
655
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
656
|
+
url = f"{instance_url}/services/data/v59.0/sobjects/Account/"
|
|
657
|
+
res = requests.get(url, headers=headers, timeout=10)
|
|
658
|
+
res.raise_for_status()
|
|
659
|
+
return res.json()
|
|
660
|
+
|
|
661
|
+
# Default simple_salesforce client for testing/sandbox
|
|
662
|
+
sf = Salesforce(
|
|
663
|
+
username=username,
|
|
664
|
+
password=password,
|
|
665
|
+
security_token=security_token,
|
|
666
|
+
domain=domain,
|
|
667
|
+
)
|
|
668
|
+
return sf.query("SELECT Id FROM Account LIMIT 1")
|
|
669
|
+
|
|
670
|
+
data = await asyncio.to_thread(_connect)
|
|
671
|
+
if isinstance(data, dict):
|
|
672
|
+
return {"success": True, "status_code": 200, "error_message": None}
|
|
673
|
+
return {
|
|
674
|
+
"success": False,
|
|
675
|
+
"status_code": 200,
|
|
676
|
+
"error_message": "Did not receive records from Salesforce.",
|
|
677
|
+
}
|
|
678
|
+
except Exception as e:
|
|
679
|
+
status = getattr(e, "status", 0)
|
|
680
|
+
logger.error(f"Salesforce test failed: {e}")
|
|
681
|
+
return {"success": False, "status_code": status, "error_message": str(e)}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
async def test_github(api_key: str) -> Dict[str, Any]:
|
|
685
|
+
"""
|
|
686
|
+
Tests GitHub API connectivity using a Personal Access Token (PAT).
|
|
687
|
+
Performs a GET /user call to verify token validity.
|
|
688
|
+
"""
|
|
689
|
+
url = "https://api.github.com/user"
|
|
690
|
+
headers = {
|
|
691
|
+
"Authorization": f"token {api_key}",
|
|
692
|
+
"Accept": "application/vnd.github+json",
|
|
693
|
+
}
|
|
694
|
+
try:
|
|
695
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
696
|
+
async with session.get(url, headers=headers) as response:
|
|
697
|
+
status = response.status
|
|
698
|
+
data = await safe_json(response)
|
|
699
|
+
|
|
700
|
+
if status != 200:
|
|
701
|
+
error_message = data.get("message", f"Non-200 from GitHub: {status}") if data else None
|
|
702
|
+
return {
|
|
703
|
+
"success": False,
|
|
704
|
+
"status_code": status,
|
|
705
|
+
"error_message": error_message
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if data and "login" in data:
|
|
709
|
+
return {
|
|
710
|
+
"success": True,
|
|
711
|
+
"status_code": status,
|
|
712
|
+
"error_message": None
|
|
713
|
+
}
|
|
714
|
+
else:
|
|
715
|
+
return {
|
|
716
|
+
"success": False,
|
|
717
|
+
"status_code": status,
|
|
718
|
+
"error_message": "GitHub API responded but 'login' not found."
|
|
719
|
+
}
|
|
720
|
+
except Exception as e:
|
|
721
|
+
logger.error(f"GitHub connectivity test failed: {e}")
|
|
722
|
+
return {
|
|
723
|
+
"success": False,
|
|
724
|
+
"status_code": 0,
|
|
725
|
+
"error_message": str(e)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
###############################################################################
|
|
729
|
+
# UPDATED test_findyemail TO REFLECT ACTUAL USAGE
|
|
730
|
+
###############################################################################
|
|
731
|
+
|
|
732
|
+
async def test_findyemail(api_key: str) -> Dict[str, Any]:
|
|
733
|
+
"""
|
|
734
|
+
Tests FindyMail by sending a POST request to /search/name
|
|
735
|
+
with a dummy name+domain, matching the usage in guess_email_with_findymail.
|
|
736
|
+
"""
|
|
737
|
+
url = f"{FINDYMAIL_BASE_URL}/search/name"
|
|
738
|
+
headers = {
|
|
739
|
+
"Authorization": f"Bearer {api_key}",
|
|
740
|
+
"Content-Type": "application/json"
|
|
741
|
+
}
|
|
742
|
+
payload = {
|
|
743
|
+
"name": "Satya Nadella",
|
|
744
|
+
"domain": "microsoft.com"
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
try:
|
|
748
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
749
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
750
|
+
status = response.status
|
|
751
|
+
data = await safe_json(response)
|
|
752
|
+
|
|
753
|
+
if status != 200:
|
|
754
|
+
return {
|
|
755
|
+
"success": False,
|
|
756
|
+
"status_code": status,
|
|
757
|
+
"error_message": f"[FindyMail] Non-200: {status}"
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
# On success, we usually get { "contact": { ... } }
|
|
761
|
+
contact = data.get("contact")
|
|
762
|
+
if not contact:
|
|
763
|
+
return {
|
|
764
|
+
"success": False,
|
|
765
|
+
"status_code": status,
|
|
766
|
+
"error_message": "No 'contact' field in response. Possibly invalid API key or insufficient data."
|
|
767
|
+
}
|
|
768
|
+
# If we got here, assume success
|
|
769
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
770
|
+
except Exception as e:
|
|
771
|
+
logger.error(f"FindyEmail test failed: {e}")
|
|
772
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
773
|
+
|
|
774
|
+
###############################################################################
|
|
775
|
+
# UPDATED test_hunter TO REFLECT ACTUAL USAGE
|
|
776
|
+
###############################################################################
|
|
777
|
+
|
|
778
|
+
async def test_hunter(api_key: str) -> Dict[str, Any]:
|
|
779
|
+
"""
|
|
780
|
+
Tests Hunter by calling their /v2/email-finder endpoint with dummy parameters,
|
|
781
|
+
mirroring guess_email_with_hunter usage.
|
|
782
|
+
"""
|
|
783
|
+
# Example dummy usage with domain=example.com, first_name=John, last_name=Doe
|
|
784
|
+
base_url = "https://api.hunter.io/v2/email-finder"
|
|
785
|
+
url = f"{base_url}?domain=microsoft.com&first_name=Satya&last_name=Nadella&api_key={api_key}"
|
|
786
|
+
|
|
787
|
+
try:
|
|
788
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
789
|
+
async with session.get(url) as response:
|
|
790
|
+
status = response.status
|
|
791
|
+
data = await safe_json(response)
|
|
792
|
+
if status != 200:
|
|
793
|
+
logger.warning("[Hunter] email-finder non‑200: %s", status)
|
|
794
|
+
return {
|
|
795
|
+
"success": False,
|
|
796
|
+
"status_code": status,
|
|
797
|
+
"error_message": f"Hunter responded with {status}"
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
# On success, check if we got an email in data->"data"->"email"
|
|
801
|
+
email = data.get("data", {}).get("email")
|
|
802
|
+
if not email:
|
|
803
|
+
return {
|
|
804
|
+
"success": False,
|
|
805
|
+
"status_code": status,
|
|
806
|
+
"error_message": "No email found in Hunter response. Possibly invalid API key or no data."
|
|
807
|
+
}
|
|
808
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
809
|
+
except Exception as ex:
|
|
810
|
+
logger.exception("[Hunter] test exception: %s", ex)
|
|
811
|
+
return {"success": False, "status_code": 0, "error_message": str(ex)}
|
|
812
|
+
|
|
813
|
+
###############################################################################
|
|
814
|
+
# CLAY CONNECTIVITY TEST
|
|
815
|
+
###############################################################################
|
|
816
|
+
|
|
817
|
+
async def test_clay(api_key: str, webhook: str) -> Dict[str, Any]:
|
|
818
|
+
"""Send a simple payload to the Clay webhook to verify credentials."""
|
|
819
|
+
dummy_lead = {
|
|
820
|
+
"first_name": "Test",
|
|
821
|
+
"last_name": "User",
|
|
822
|
+
"email": "test@example.com",
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
try:
|
|
826
|
+
result = await push_to_clay_table(dummy_lead, webhook=webhook, api_key=api_key)
|
|
827
|
+
if isinstance(result, dict) and "error" in result:
|
|
828
|
+
return {
|
|
829
|
+
"success": False,
|
|
830
|
+
"status_code": 0,
|
|
831
|
+
"error_message": result["error"],
|
|
832
|
+
}
|
|
833
|
+
return {"success": True, "status_code": 200, "error_message": None}
|
|
834
|
+
except Exception as exc: # network or other
|
|
835
|
+
logger.error(f"Clay test failed: {exc}")
|
|
836
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
837
|
+
|
|
838
|
+
###############################################################################
|
|
839
|
+
# POSTHOG CONNECTIVITY TEST
|
|
840
|
+
###############################################################################
|
|
841
|
+
|
|
842
|
+
async def test_posthog(
|
|
843
|
+
api_host: str,
|
|
844
|
+
project_id: str,
|
|
845
|
+
personal_api_key: str,
|
|
846
|
+
) -> Dict[str, Any]:
|
|
847
|
+
"""
|
|
848
|
+
Validate PostHog connectivity by issuing a lightweight HogQL query.
|
|
849
|
+
|
|
850
|
+
Requires:
|
|
851
|
+
• api_host (e.g. https://app.posthog.com or self-hosted URL)
|
|
852
|
+
• project_id (numeric or string project identifier)
|
|
853
|
+
• personal_api_key (token with query access)
|
|
854
|
+
"""
|
|
855
|
+
base_url = (api_host or "").rstrip("/")
|
|
856
|
+
if not base_url:
|
|
857
|
+
return {
|
|
858
|
+
"success": False,
|
|
859
|
+
"status_code": 0,
|
|
860
|
+
"error_message": "Missing api_host for PostHog connectivity test.",
|
|
861
|
+
}
|
|
862
|
+
if not project_id:
|
|
863
|
+
return {
|
|
864
|
+
"success": False,
|
|
865
|
+
"status_code": 0,
|
|
866
|
+
"error_message": "Missing project_id for PostHog connectivity test.",
|
|
867
|
+
}
|
|
868
|
+
if not personal_api_key:
|
|
869
|
+
return {
|
|
870
|
+
"success": False,
|
|
871
|
+
"status_code": 0,
|
|
872
|
+
"error_message": "Missing personal_api_key for PostHog connectivity test.",
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
url = f"{base_url}/api/projects/{project_id}/query/"
|
|
876
|
+
headers = {
|
|
877
|
+
"Authorization": f"Bearer {personal_api_key}",
|
|
878
|
+
"Content-Type": "application/json",
|
|
879
|
+
}
|
|
880
|
+
payload = {"query": {"kind": "HogQLQuery", "query": "SELECT 1"}}
|
|
881
|
+
|
|
882
|
+
try:
|
|
883
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
884
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
885
|
+
status = response.status
|
|
886
|
+
data = await safe_json(response)
|
|
887
|
+
|
|
888
|
+
if status == 200:
|
|
889
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
890
|
+
|
|
891
|
+
detail = None
|
|
892
|
+
if isinstance(data, dict):
|
|
893
|
+
detail = (
|
|
894
|
+
data.get("message")
|
|
895
|
+
or data.get("detail")
|
|
896
|
+
or data.get("error")
|
|
897
|
+
or data.get("code")
|
|
898
|
+
)
|
|
899
|
+
return {
|
|
900
|
+
"success": False,
|
|
901
|
+
"status_code": status,
|
|
902
|
+
"error_message": detail or f"PostHog responded with {status}",
|
|
903
|
+
}
|
|
904
|
+
except Exception as exc:
|
|
905
|
+
logger.error(f"PostHog connectivity test failed: {exc}")
|
|
906
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
907
|
+
|
|
908
|
+
###############################################################################
|
|
909
|
+
# MCP SERVER CONNECTIVITY TEST
|
|
910
|
+
###############################################################################
|
|
911
|
+
|
|
912
|
+
async def test_mcp_server(
|
|
913
|
+
base_url: str,
|
|
914
|
+
server_label: str = "",
|
|
915
|
+
header_name: str = "",
|
|
916
|
+
header_value: str = ""
|
|
917
|
+
) -> Dict[str, Any]:
|
|
918
|
+
"""Simple connectivity check for an MCP server using the OpenAI client."""
|
|
919
|
+
|
|
920
|
+
if AsyncOpenAI is None:
|
|
921
|
+
return {
|
|
922
|
+
"success": False,
|
|
923
|
+
"status_code": 0,
|
|
924
|
+
"error_message": "openai package not installed",
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
api_key = os.getenv("OPENAI_API_KEY")
|
|
928
|
+
if not api_key:
|
|
929
|
+
return {
|
|
930
|
+
"success": False,
|
|
931
|
+
"status_code": 0,
|
|
932
|
+
"error_message": "OPENAI_API_KEY environment variable not set",
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
headers: Dict[str, str] = (
|
|
936
|
+
{header_name: header_value} if header_name and header_value else {}
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
tools = [
|
|
940
|
+
{
|
|
941
|
+
"type": "mcp",
|
|
942
|
+
"server_label": server_label,
|
|
943
|
+
"server_url": base_url,
|
|
944
|
+
"require_approval": "never",
|
|
945
|
+
"headers": headers,
|
|
946
|
+
}
|
|
947
|
+
]
|
|
948
|
+
|
|
949
|
+
try:
|
|
950
|
+
client = AsyncOpenAI(api_key=api_key)
|
|
951
|
+
|
|
952
|
+
kwargs: Dict[str, Any] = {
|
|
953
|
+
"input": [
|
|
954
|
+
{"role": "user", "content": "list tools available"},
|
|
955
|
+
],
|
|
956
|
+
"model": "gpt-4",
|
|
957
|
+
"store": False,
|
|
958
|
+
"tools": tools,
|
|
959
|
+
"tool_choice": "required",
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
response = await client.responses.create(**kwargs)
|
|
963
|
+
|
|
964
|
+
# Convert response to dict-like structure for compatibility
|
|
965
|
+
status = 200 # Successful response creation
|
|
966
|
+
data = response.model_dump() if hasattr(response, 'model_dump') else None
|
|
967
|
+
|
|
968
|
+
if data and data.get("error"):
|
|
969
|
+
detail = data["error"].get("message") if isinstance(data["error"], dict) else str(data["error"])
|
|
970
|
+
return {"success": False, "status_code": status, "error_message": detail}
|
|
971
|
+
|
|
972
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
973
|
+
except Exception as e:
|
|
974
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
###############################################################################
|
|
978
|
+
# SMTP / IMAP CONNECTIVITY TEST FUNCTION
|
|
979
|
+
###############################################################################
|
|
980
|
+
|
|
981
|
+
async def test_smtp_accounts(
|
|
982
|
+
usernames: str,
|
|
983
|
+
passwords: str,
|
|
984
|
+
smtp_host: str,
|
|
985
|
+
smtp_port: int,
|
|
986
|
+
imap_host: str,
|
|
987
|
+
imap_port: int,
|
|
988
|
+
) -> Dict[str, Any]:
|
|
989
|
+
"""
|
|
990
|
+
Quick “smoke test” for an SMTP + IMAP mailbox configuration.
|
|
991
|
+
|
|
992
|
+
Parameters
|
|
993
|
+
----------
|
|
994
|
+
usernames : str
|
|
995
|
+
Comma-separated list of mailbox usernames.
|
|
996
|
+
passwords : str
|
|
997
|
+
Comma-separated list of passwords or app-passwords, **same order** as *usernames*.
|
|
998
|
+
smtp_host : str
|
|
999
|
+
SMTP server hostname (e.g. ``smtp.gmail.com``).
|
|
1000
|
+
smtp_port : int
|
|
1001
|
+
SMTP port (587 for STARTTLS, 465 for implicit SSL, etc.).
|
|
1002
|
+
imap_host : str
|
|
1003
|
+
IMAP server hostname (e.g. ``imap.gmail.com``).
|
|
1004
|
+
imap_port : int
|
|
1005
|
+
IMAP SSL port (usually 993).
|
|
1006
|
+
|
|
1007
|
+
Returns
|
|
1008
|
+
-------
|
|
1009
|
+
dict
|
|
1010
|
+
{
|
|
1011
|
+
"success": bool,
|
|
1012
|
+
"status_code": int, # 250 for SMTP OK, or last IMAP status-code on error
|
|
1013
|
+
"error_message": Optional[str]
|
|
1014
|
+
}
|
|
1015
|
+
"""
|
|
1016
|
+
users: List[str] = [u.strip() for u in usernames.split(",") if u.strip()]
|
|
1017
|
+
pwds: List[str] = [p.strip() for p in passwords.split(",") if p.strip()]
|
|
1018
|
+
|
|
1019
|
+
if not users or len(users) != len(pwds):
|
|
1020
|
+
return {
|
|
1021
|
+
"success": False,
|
|
1022
|
+
"status_code": 0,
|
|
1023
|
+
"error_message": "Username / password list mismatch or empty."
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
# --- use the first account for the connectivity check ---
|
|
1027
|
+
user, pwd = users[0], pwds[0]
|
|
1028
|
+
|
|
1029
|
+
# 1) SMTP LOGIN ----------------------------------------------------------
|
|
1030
|
+
try:
|
|
1031
|
+
smtp_kwargs = dict(hostname=smtp_host, port=smtp_port, timeout=10)
|
|
1032
|
+
if smtp_port == 587:
|
|
1033
|
+
smtp_kwargs["start_tls"] = True # STARTTLS upgrade
|
|
1034
|
+
else:
|
|
1035
|
+
smtp_kwargs["tls"] = (smtp_port == 465) # implicit SSL on 465
|
|
1036
|
+
|
|
1037
|
+
smtp = aiosmtplib.SMTP(**smtp_kwargs)
|
|
1038
|
+
await smtp.connect()
|
|
1039
|
+
|
|
1040
|
+
code, _msg = await smtp.login(user, pwd)
|
|
1041
|
+
await smtp.quit()
|
|
1042
|
+
|
|
1043
|
+
if code not in (235, 250): # 235 = Auth OK, 250 = generic OK
|
|
1044
|
+
return {
|
|
1045
|
+
"success": False,
|
|
1046
|
+
"status_code": code,
|
|
1047
|
+
"error_message": f"SMTP login failed with code {code}"
|
|
1048
|
+
}
|
|
1049
|
+
except Exception as e:
|
|
1050
|
+
return {
|
|
1051
|
+
"success": False,
|
|
1052
|
+
"status_code": 0,
|
|
1053
|
+
"error_message": f"SMTP error: {e}"
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
# 2) IMAP LOGIN ----------------------------------------------------------
|
|
1057
|
+
try:
|
|
1058
|
+
conn = imaplib.IMAP4_SSL(imap_host, imap_port) # SSL always for 993
|
|
1059
|
+
status, _ = conn.login(user, pwd)
|
|
1060
|
+
conn.logout()
|
|
1061
|
+
|
|
1062
|
+
if status != "OK":
|
|
1063
|
+
return {
|
|
1064
|
+
"success": False,
|
|
1065
|
+
"status_code": 0,
|
|
1066
|
+
"error_message": f"IMAP login failed: {status}"
|
|
1067
|
+
}
|
|
1068
|
+
except Exception as e:
|
|
1069
|
+
return {
|
|
1070
|
+
"success": False,
|
|
1071
|
+
"status_code": 0,
|
|
1072
|
+
"error_message": f"IMAP error: {e}"
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
# ------------------------------------------------------------------------
|
|
1076
|
+
return {
|
|
1077
|
+
"success": True,
|
|
1078
|
+
"status_code": 250, # canonical “OK” code for SMTP success
|
|
1079
|
+
"error_message": None
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async def test_slack(webhook_url: str) -> Dict[str, Any]:
|
|
1083
|
+
"""
|
|
1084
|
+
Sends a test JSON payload to the provided Slack Webhook URL.
|
|
1085
|
+
Slack typically returns a 200 status with 'ok' in the body if successful.
|
|
1086
|
+
"""
|
|
1087
|
+
try:
|
|
1088
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1089
|
+
payload = {"text": "Hello from Dhisana connectivity test!"}
|
|
1090
|
+
async with session.post(webhook_url, json=payload) as response:
|
|
1091
|
+
status = response.status
|
|
1092
|
+
text_response = await response.text()
|
|
1093
|
+
|
|
1094
|
+
if status != 200:
|
|
1095
|
+
return {
|
|
1096
|
+
"success": False,
|
|
1097
|
+
"status_code": status,
|
|
1098
|
+
"error_message": f"Slack webhook returned non-200 status: {status}"
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
# Slack returns "ok" if the message was posted successfully
|
|
1102
|
+
if text_response.strip().lower() != "ok":
|
|
1103
|
+
return {
|
|
1104
|
+
"success": False,
|
|
1105
|
+
"status_code": status,
|
|
1106
|
+
"error_message": f"Unexpected Slack response: {text_response}"
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1110
|
+
|
|
1111
|
+
except Exception as e:
|
|
1112
|
+
logger.error(f"Slack connectivity test failed: {e}")
|
|
1113
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
async def test_jinaai(api_key: str) -> Dict[str, Any]:
|
|
1117
|
+
"""Simple connectivity test for the Jina AI API."""
|
|
1118
|
+
url = "https://api.jina.ai/v1/embeddings"
|
|
1119
|
+
headers = {
|
|
1120
|
+
"Authorization": f"Bearer {api_key}",
|
|
1121
|
+
"Content-Type": "application/json"
|
|
1122
|
+
}
|
|
1123
|
+
payload = {
|
|
1124
|
+
"model": "jina-embeddings-v2-base-en",
|
|
1125
|
+
"input": ["ping"]
|
|
1126
|
+
}
|
|
1127
|
+
try:
|
|
1128
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1129
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
1130
|
+
status = response.status
|
|
1131
|
+
data = await safe_json(response)
|
|
1132
|
+
|
|
1133
|
+
if status != 200:
|
|
1134
|
+
message = data.get("message") if isinstance(data, dict) else None
|
|
1135
|
+
return {
|
|
1136
|
+
"success": False,
|
|
1137
|
+
"status_code": status,
|
|
1138
|
+
"error_message": message or f"Non-200 from Jina AI: {status}",
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if data and "error" in data:
|
|
1142
|
+
err = data["error"]
|
|
1143
|
+
if isinstance(err, dict):
|
|
1144
|
+
err = err.get("message", str(err))
|
|
1145
|
+
return {"success": False, "status_code": status, "error_message": err}
|
|
1146
|
+
|
|
1147
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1148
|
+
except Exception as exc:
|
|
1149
|
+
logger.error(f"Jina AI connectivity test failed: {exc}")
|
|
1150
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
async def test_firefliesai(api_key: str) -> Dict[str, Any]:
|
|
1154
|
+
"""Validate Fireflies.ai API key by querying user metadata via GraphQL."""
|
|
1155
|
+
url = "https://api.fireflies.ai/graphql"
|
|
1156
|
+
headers = {
|
|
1157
|
+
"Authorization": f"Bearer {api_key}",
|
|
1158
|
+
"Content-Type": "application/json",
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
# Try a couple of documented/observed query shapes — Fireflies occasionally
|
|
1162
|
+
# aliases the viewer field, so we fall back if the first choice is rejected.
|
|
1163
|
+
queries = [
|
|
1164
|
+
("users", {"query": "{ users { name user_id } }"}, ("data", "users")),
|
|
1165
|
+
("viewer", {"query": "query { viewer { id email } }"}, ("data", "viewer")),
|
|
1166
|
+
("me", {"query": "query { me { id email } }"}, ("data", "me")),
|
|
1167
|
+
("currentUser", {"query": "query { currentUser { id email } }"}, ("data", "currentUser")),
|
|
1168
|
+
]
|
|
1169
|
+
|
|
1170
|
+
def extract_error(payload: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
1171
|
+
if not isinstance(payload, dict):
|
|
1172
|
+
return None
|
|
1173
|
+
errors = payload.get("errors")
|
|
1174
|
+
if isinstance(errors, list):
|
|
1175
|
+
messages = [
|
|
1176
|
+
err.get("message") for err in errors
|
|
1177
|
+
if isinstance(err, dict) and err.get("message")
|
|
1178
|
+
]
|
|
1179
|
+
if messages:
|
|
1180
|
+
return "; ".join(messages)
|
|
1181
|
+
elif errors:
|
|
1182
|
+
return str(errors)
|
|
1183
|
+
return (
|
|
1184
|
+
payload.get("message")
|
|
1185
|
+
or payload.get("error_description")
|
|
1186
|
+
or payload.get("error")
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
try:
|
|
1190
|
+
timeout = aiohttp.ClientTimeout(total=10)
|
|
1191
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
1192
|
+
last_error: Optional[str] = None
|
|
1193
|
+
|
|
1194
|
+
for query_name, payload, data_path in queries:
|
|
1195
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
1196
|
+
status = response.status
|
|
1197
|
+
data = await safe_json(response)
|
|
1198
|
+
|
|
1199
|
+
if status != 200:
|
|
1200
|
+
error_message = extract_error(data)
|
|
1201
|
+
if (
|
|
1202
|
+
error_message
|
|
1203
|
+
and "Cannot query field" in error_message
|
|
1204
|
+
and query_name != queries[-1][0]
|
|
1205
|
+
):
|
|
1206
|
+
last_error = error_message
|
|
1207
|
+
continue
|
|
1208
|
+
return {
|
|
1209
|
+
"success": False,
|
|
1210
|
+
"status_code": status,
|
|
1211
|
+
"error_message": error_message or f"Non-200 from Fireflies.ai ({query_name})",
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if not isinstance(data, dict):
|
|
1215
|
+
return {
|
|
1216
|
+
"success": False,
|
|
1217
|
+
"status_code": status,
|
|
1218
|
+
"error_message": "Fireflies.ai returned non-JSON response.",
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
error_message = extract_error(data)
|
|
1222
|
+
if error_message:
|
|
1223
|
+
last_error = error_message
|
|
1224
|
+
# If the error indicates the field is unknown, try the next query option.
|
|
1225
|
+
if "Cannot query field" in error_message and query_name != queries[-1][0]:
|
|
1226
|
+
continue
|
|
1227
|
+
return {
|
|
1228
|
+
"success": False,
|
|
1229
|
+
"status_code": status,
|
|
1230
|
+
"error_message": error_message,
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
# Walk the data path to ensure the expected field exists.
|
|
1234
|
+
cursor: Any = data
|
|
1235
|
+
for key in data_path:
|
|
1236
|
+
if not isinstance(cursor, dict):
|
|
1237
|
+
cursor = None
|
|
1238
|
+
break
|
|
1239
|
+
cursor = cursor.get(key)
|
|
1240
|
+
|
|
1241
|
+
if cursor is not None:
|
|
1242
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1243
|
+
|
|
1244
|
+
last_error = f"Fireflies.ai {query_name} response missing expected fields."
|
|
1245
|
+
|
|
1246
|
+
return {
|
|
1247
|
+
"success": False,
|
|
1248
|
+
"status_code": 200,
|
|
1249
|
+
"error_message": last_error or "Fireflies.ai queries did not return user data.",
|
|
1250
|
+
}
|
|
1251
|
+
except Exception as exc:
|
|
1252
|
+
logger.error(f"Fireflies.ai connectivity test failed: {exc}")
|
|
1253
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
async def test_firecrawl(api_key: str) -> Dict[str, Any]:
|
|
1257
|
+
"""Quick check for Firecrawl API key validity (official v1 endpoint)."""
|
|
1258
|
+
url = "https://api.firecrawl.dev/v1/scrape"
|
|
1259
|
+
headers = {
|
|
1260
|
+
"Authorization": f"Bearer {api_key}", # per Firecrawl v1 docs
|
|
1261
|
+
"Content-Type": "application/json",
|
|
1262
|
+
}
|
|
1263
|
+
payload = {"url": "https://example.com"}
|
|
1264
|
+
|
|
1265
|
+
try:
|
|
1266
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1267
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
1268
|
+
status = response.status
|
|
1269
|
+
data = await safe_json(response)
|
|
1270
|
+
|
|
1271
|
+
if status != 200:
|
|
1272
|
+
message = data.get("message") if isinstance(data, dict) else None
|
|
1273
|
+
return {
|
|
1274
|
+
"success": False,
|
|
1275
|
+
"status_code": status,
|
|
1276
|
+
"error_message": message or f"Non-200 from Firecrawl: {status}",
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if data and "error" in data:
|
|
1280
|
+
err = data["error"]
|
|
1281
|
+
if isinstance(err, dict):
|
|
1282
|
+
err = err.get("message", str(err))
|
|
1283
|
+
return {"success": False, "status_code": status, "error_message": err}
|
|
1284
|
+
|
|
1285
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1286
|
+
except Exception as exc:
|
|
1287
|
+
logger.error(f"Firecrawl connectivity test failed: {exc}")
|
|
1288
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
async def test_scraperapi(api_key: str) -> Dict[str, Any]:
|
|
1292
|
+
"""Connectivity check for ScraperAPI using a simple NYTimes scrape."""
|
|
1293
|
+
url = "https://api.scraperapi.com/"
|
|
1294
|
+
params = {
|
|
1295
|
+
"api_key": api_key,
|
|
1296
|
+
"url": "https://example.com/", # lightweight public page to minimize credit usage
|
|
1297
|
+
"output_format": "markdown",
|
|
1298
|
+
}
|
|
1299
|
+
try:
|
|
1300
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1301
|
+
async with session.get(url, params=params) as response:
|
|
1302
|
+
status = response.status
|
|
1303
|
+
body_text = await response.text()
|
|
1304
|
+
|
|
1305
|
+
if status != 200:
|
|
1306
|
+
snippet = body_text[:200] if body_text else None
|
|
1307
|
+
return {
|
|
1308
|
+
"success": False,
|
|
1309
|
+
"status_code": status,
|
|
1310
|
+
"error_message": snippet or f"Non-200 from ScraperAPI: {status}",
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if not body_text:
|
|
1314
|
+
return {
|
|
1315
|
+
"success": False,
|
|
1316
|
+
"status_code": status,
|
|
1317
|
+
"error_message": "ScraperAPI returned an empty response.",
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1321
|
+
except Exception as exc:
|
|
1322
|
+
logger.error(f"ScraperAPI connectivity test failed: {exc}")
|
|
1323
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
async def test_youtube(api_key: str) -> Dict[str, Any]:
|
|
1327
|
+
"""
|
|
1328
|
+
Tests YouTube Data API v3 by making a simple search request.
|
|
1329
|
+
Uses a basic search query that works with API key authentication only.
|
|
1330
|
+
"""
|
|
1331
|
+
url = "https://www.googleapis.com/youtube/v3/search"
|
|
1332
|
+
params = {
|
|
1333
|
+
"part": "snippet",
|
|
1334
|
+
"q": "test",
|
|
1335
|
+
"type": "video",
|
|
1336
|
+
"maxResults": 1,
|
|
1337
|
+
"key": api_key
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
try:
|
|
1341
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1342
|
+
async with session.get(url, params=params) as response:
|
|
1343
|
+
status = response.status
|
|
1344
|
+
|
|
1345
|
+
# Get response text first for debugging
|
|
1346
|
+
response_text = await response.text()
|
|
1347
|
+
logger.debug(f"YouTube API response status: {status}, text: {response_text[:500]}")
|
|
1348
|
+
|
|
1349
|
+
# Try to parse as JSON
|
|
1350
|
+
data = None
|
|
1351
|
+
try:
|
|
1352
|
+
data = json.loads(response_text) if response_text else None
|
|
1353
|
+
except json.JSONDecodeError:
|
|
1354
|
+
logger.warning(f"YouTube API returned non-JSON response: {response_text[:200]}")
|
|
1355
|
+
|
|
1356
|
+
if status != 200:
|
|
1357
|
+
error_message = None
|
|
1358
|
+
if data and isinstance(data, dict):
|
|
1359
|
+
error = data.get("error", {})
|
|
1360
|
+
if isinstance(error, dict):
|
|
1361
|
+
error_message = error.get("message")
|
|
1362
|
+
else:
|
|
1363
|
+
error_message = str(error)
|
|
1364
|
+
|
|
1365
|
+
return {
|
|
1366
|
+
"success": False,
|
|
1367
|
+
"status_code": status,
|
|
1368
|
+
"error_message": error_message or f"Non-200 from YouTube API: {status}. Response: {response_text[:200]}"
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
# Handle case where we got 200 but no valid JSON data
|
|
1372
|
+
if not data:
|
|
1373
|
+
return {
|
|
1374
|
+
"success": False,
|
|
1375
|
+
"status_code": status,
|
|
1376
|
+
"error_message": f"YouTube API returned empty or invalid JSON response: {response_text[:200]}"
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
# Check for API errors in 200 response
|
|
1380
|
+
if "error" in data:
|
|
1381
|
+
error = data["error"]
|
|
1382
|
+
error_message = error.get("message") if isinstance(error, dict) else str(error)
|
|
1383
|
+
return {
|
|
1384
|
+
"success": False,
|
|
1385
|
+
"status_code": status,
|
|
1386
|
+
"error_message": f"YouTube API error: {error_message}"
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
# Check if we got valid response structure
|
|
1390
|
+
if "kind" in data and data["kind"] == "youtube#searchListResponse":
|
|
1391
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1392
|
+
|
|
1393
|
+
return {
|
|
1394
|
+
"success": False,
|
|
1395
|
+
"status_code": status,
|
|
1396
|
+
"error_message": f"Invalid response format from YouTube API. Expected 'youtube#searchListResponse', got: {data.get('kind', 'unknown')}"
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
except Exception as exc:
|
|
1400
|
+
logger.error(f"YouTube API connectivity test failed: {exc}")
|
|
1401
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
async def test_orum(api_key: str) -> Dict[str, Any]:
|
|
1405
|
+
"""
|
|
1406
|
+
Validate an Orum API key by calling a lightweight authenticated endpoint.
|
|
1407
|
+
|
|
1408
|
+
The base URL can be overridden with ORUM_API_BASE_URL if needed.
|
|
1409
|
+
"""
|
|
1410
|
+
base_url = os.getenv("ORUM_API_BASE_URL", "https://api.orum.com")
|
|
1411
|
+
url = f"{base_url.rstrip('/')}/api/v1/users/me"
|
|
1412
|
+
headers = {
|
|
1413
|
+
"Authorization": f"Bearer {api_key}",
|
|
1414
|
+
"Accept": "application/json",
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
try:
|
|
1418
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1419
|
+
async with session.get(url, headers=headers) as response:
|
|
1420
|
+
status = response.status
|
|
1421
|
+
data = await safe_json(response)
|
|
1422
|
+
|
|
1423
|
+
if status == 200:
|
|
1424
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1425
|
+
|
|
1426
|
+
message = None
|
|
1427
|
+
if isinstance(data, dict):
|
|
1428
|
+
message = data.get("message") or data.get("error") or data.get("detail")
|
|
1429
|
+
return {
|
|
1430
|
+
"success": False,
|
|
1431
|
+
"status_code": status,
|
|
1432
|
+
"error_message": message or f"Orum responded with {status}",
|
|
1433
|
+
}
|
|
1434
|
+
except Exception as exc:
|
|
1435
|
+
logger.error(f"Orum connectivity test failed: {exc}")
|
|
1436
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
async def test_aircall(app_id: str, api_token: str) -> Dict[str, Any]:
|
|
1440
|
+
"""
|
|
1441
|
+
Validate Aircall credentials via a lightweight authenticated call.
|
|
1442
|
+
Uses HTTP Basic Auth (app_id:api_token).
|
|
1443
|
+
"""
|
|
1444
|
+
url = "https://api.aircall.io/v1/users"
|
|
1445
|
+
params = {"per_page": 1}
|
|
1446
|
+
|
|
1447
|
+
try:
|
|
1448
|
+
auth = aiohttp.BasicAuth(app_id, api_token)
|
|
1449
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10), auth=auth) as session:
|
|
1450
|
+
async with session.get(url, params=params) as response:
|
|
1451
|
+
status = response.status
|
|
1452
|
+
data = await safe_json(response)
|
|
1453
|
+
|
|
1454
|
+
if status == 200 and isinstance(data, dict) and "users" in data:
|
|
1455
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1456
|
+
|
|
1457
|
+
message = None
|
|
1458
|
+
if isinstance(data, dict):
|
|
1459
|
+
message = data.get("message") or data.get("error")
|
|
1460
|
+
return {
|
|
1461
|
+
"success": False,
|
|
1462
|
+
"status_code": status,
|
|
1463
|
+
"error_message": message or f"Aircall responded with {status}",
|
|
1464
|
+
}
|
|
1465
|
+
except Exception as exc:
|
|
1466
|
+
logger.error(f"Aircall connectivity test failed: {exc}")
|
|
1467
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
async def test_ringover(api_key: str) -> Dict[str, Any]:
|
|
1471
|
+
"""
|
|
1472
|
+
Validate Ringover API key using a minimal authenticated request.
|
|
1473
|
+
"""
|
|
1474
|
+
base_url = os.getenv("RINGOVER_API_BASE_URL", "https://public-api.ringover.com")
|
|
1475
|
+
url = f"{base_url.rstrip('/')}/v2/users"
|
|
1476
|
+
headers = {
|
|
1477
|
+
"X-API-KEY": api_key,
|
|
1478
|
+
"Accept": "application/json",
|
|
1479
|
+
}
|
|
1480
|
+
params = {"limit": 1}
|
|
1481
|
+
|
|
1482
|
+
try:
|
|
1483
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1484
|
+
async with session.get(url, headers=headers, params=params) as response:
|
|
1485
|
+
status = response.status
|
|
1486
|
+
data = await safe_json(response)
|
|
1487
|
+
|
|
1488
|
+
if status == 200:
|
|
1489
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1490
|
+
|
|
1491
|
+
message = None
|
|
1492
|
+
if isinstance(data, dict):
|
|
1493
|
+
message = data.get("message") or data.get("error") or data.get("detail")
|
|
1494
|
+
return {
|
|
1495
|
+
"success": False,
|
|
1496
|
+
"status_code": status,
|
|
1497
|
+
"error_message": message or f"Ringover responded with {status}",
|
|
1498
|
+
}
|
|
1499
|
+
except Exception as exc:
|
|
1500
|
+
logger.error(f"Ringover connectivity test failed: {exc}")
|
|
1501
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
async def test_dialpad(client_id: str, client_secret: str) -> Dict[str, Any]:
|
|
1505
|
+
"""
|
|
1506
|
+
Validate Dialpad client credentials via client_credentials token exchange, then whoami.
|
|
1507
|
+
"""
|
|
1508
|
+
base_url = os.getenv("DIALPAD_API_BASE_URL", "https://dialpad.com")
|
|
1509
|
+
token_url = f"{base_url.rstrip('/')}/oauth/token"
|
|
1510
|
+
whoami_url = f"{base_url.rstrip('/')}/api/v2/whoami"
|
|
1511
|
+
|
|
1512
|
+
try:
|
|
1513
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1514
|
+
token_resp = await session.post(
|
|
1515
|
+
token_url,
|
|
1516
|
+
data={
|
|
1517
|
+
"grant_type": "client_credentials",
|
|
1518
|
+
"client_id": client_id,
|
|
1519
|
+
"client_secret": client_secret,
|
|
1520
|
+
},
|
|
1521
|
+
headers={"Accept": "application/json"},
|
|
1522
|
+
)
|
|
1523
|
+
token_status = token_resp.status
|
|
1524
|
+
token_data = await safe_json(token_resp)
|
|
1525
|
+
|
|
1526
|
+
access_token = None
|
|
1527
|
+
if isinstance(token_data, dict):
|
|
1528
|
+
access_token = token_data.get("access_token")
|
|
1529
|
+
|
|
1530
|
+
if token_status != 200 or not access_token:
|
|
1531
|
+
message = None
|
|
1532
|
+
if isinstance(token_data, dict):
|
|
1533
|
+
message = token_data.get("error_description") or token_data.get("error")
|
|
1534
|
+
return {
|
|
1535
|
+
"success": False,
|
|
1536
|
+
"status_code": token_status,
|
|
1537
|
+
"error_message": message or "Failed to obtain Dialpad access token.",
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
headers = {
|
|
1541
|
+
"Authorization": f"Bearer {access_token}",
|
|
1542
|
+
"Accept": "application/json",
|
|
1543
|
+
}
|
|
1544
|
+
async with session.get(whoami_url, headers=headers) as response:
|
|
1545
|
+
status = response.status
|
|
1546
|
+
data = await safe_json(response)
|
|
1547
|
+
|
|
1548
|
+
if status == 200:
|
|
1549
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1550
|
+
|
|
1551
|
+
message = None
|
|
1552
|
+
if isinstance(data, dict):
|
|
1553
|
+
message = data.get("message") or data.get("error") or data.get("detail")
|
|
1554
|
+
return {
|
|
1555
|
+
"success": False,
|
|
1556
|
+
"status_code": status,
|
|
1557
|
+
"error_message": message or f"Dialpad responded with {status}",
|
|
1558
|
+
}
|
|
1559
|
+
except Exception as exc:
|
|
1560
|
+
logger.error(f"Dialpad connectivity test failed: {exc}")
|
|
1561
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
async def test_nooks(api_key: str) -> Dict[str, Any]:
|
|
1565
|
+
"""
|
|
1566
|
+
Validate Nooks.ai API key via a simple authenticated call.
|
|
1567
|
+
"""
|
|
1568
|
+
base_url = os.getenv("NOOKS_API_BASE_URL", "https://api.nooks.ai")
|
|
1569
|
+
url = f"{base_url.rstrip('/')}/v1/users/me"
|
|
1570
|
+
headers = {
|
|
1571
|
+
"Authorization": f"Bearer {api_key}",
|
|
1572
|
+
"Accept": "application/json",
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
try:
|
|
1576
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1577
|
+
async with session.get(url, headers=headers) as response:
|
|
1578
|
+
status = response.status
|
|
1579
|
+
data = await safe_json(response)
|
|
1580
|
+
|
|
1581
|
+
if status == 200:
|
|
1582
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1583
|
+
|
|
1584
|
+
message = None
|
|
1585
|
+
if isinstance(data, dict):
|
|
1586
|
+
message = data.get("message") or data.get("error") or data.get("detail")
|
|
1587
|
+
return {
|
|
1588
|
+
"success": False,
|
|
1589
|
+
"status_code": status,
|
|
1590
|
+
"error_message": message or f"Nooks.ai responded with {status}",
|
|
1591
|
+
}
|
|
1592
|
+
except Exception as exc:
|
|
1593
|
+
logger.error(f"Nooks.ai connectivity test failed: {exc}")
|
|
1594
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
async def test_commonroom(api_key: str) -> Dict[str, Any]:
|
|
1598
|
+
"""Validate a Common Room API token via the token status endpoint."""
|
|
1599
|
+
url = "https://api.commonroom.io/community/v1/api-token-status"
|
|
1600
|
+
headers = {
|
|
1601
|
+
"Authorization": f"Bearer {api_key}",
|
|
1602
|
+
"Accept": "application/json",
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
try:
|
|
1606
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1607
|
+
async with session.get(url, headers=headers) as response:
|
|
1608
|
+
status = response.status
|
|
1609
|
+
data = await safe_json(response)
|
|
1610
|
+
|
|
1611
|
+
if status == 200:
|
|
1612
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1613
|
+
|
|
1614
|
+
message = None
|
|
1615
|
+
if isinstance(data, dict):
|
|
1616
|
+
message = (
|
|
1617
|
+
data.get("message")
|
|
1618
|
+
or data.get("reason")
|
|
1619
|
+
or data.get("error")
|
|
1620
|
+
or data.get("docs")
|
|
1621
|
+
)
|
|
1622
|
+
return {
|
|
1623
|
+
"success": False,
|
|
1624
|
+
"status_code": status,
|
|
1625
|
+
"error_message": message or f"Common Room responded with {status}",
|
|
1626
|
+
}
|
|
1627
|
+
except Exception as exc:
|
|
1628
|
+
logger.error(f"Common Room connectivity test failed: {exc}")
|
|
1629
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
async def test_scarf(api_key: str) -> Dict[str, Any]:
|
|
1633
|
+
"""
|
|
1634
|
+
Validate a Scarf API token via the lightweight /v2/search endpoint.
|
|
1635
|
+
|
|
1636
|
+
The endpoint requires only the bearer token and a simple query string.
|
|
1637
|
+
"""
|
|
1638
|
+
url = "https://api.scarf.sh/v2/search"
|
|
1639
|
+
headers = {
|
|
1640
|
+
"Authorization": f"Bearer {api_key}",
|
|
1641
|
+
"Content-Type": "application/json",
|
|
1642
|
+
}
|
|
1643
|
+
payload = {"query": "dhisana connectivity test"}
|
|
1644
|
+
|
|
1645
|
+
try:
|
|
1646
|
+
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
|
1647
|
+
async with session.post(url, headers=headers, json=payload) as response:
|
|
1648
|
+
status = response.status
|
|
1649
|
+
data = await safe_json(response)
|
|
1650
|
+
|
|
1651
|
+
if status != 200:
|
|
1652
|
+
message = None
|
|
1653
|
+
if isinstance(data, dict):
|
|
1654
|
+
message = data.get("message") or data.get("error") or data.get("detail")
|
|
1655
|
+
return {
|
|
1656
|
+
"success": False,
|
|
1657
|
+
"status_code": status,
|
|
1658
|
+
"error_message": message or f"Scarf responded with {status}",
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
if isinstance(data, dict) and "results" in data:
|
|
1662
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1663
|
+
|
|
1664
|
+
return {
|
|
1665
|
+
"success": False,
|
|
1666
|
+
"status_code": status,
|
|
1667
|
+
"error_message": "Unexpected Scarf response payload.",
|
|
1668
|
+
}
|
|
1669
|
+
except Exception as exc:
|
|
1670
|
+
logger.error(f"Scarf connectivity test failed: {exc}")
|
|
1671
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
async def test_theorg(api_key: str) -> Dict[str, Any]:
|
|
1675
|
+
"""Validate The Org API key by calling the Usage endpoint."""
|
|
1676
|
+
url = "https://api.theorg.com/v1.1/usage"
|
|
1677
|
+
headers = {
|
|
1678
|
+
"X-Api-Key": api_key,
|
|
1679
|
+
"Accept": "application/json",
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
try:
|
|
1683
|
+
timeout = aiohttp.ClientTimeout(total=10)
|
|
1684
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
1685
|
+
async with session.get(url, headers=headers) as response:
|
|
1686
|
+
status = response.status
|
|
1687
|
+
data = await safe_json(response)
|
|
1688
|
+
|
|
1689
|
+
if status == 200:
|
|
1690
|
+
payload = data if isinstance(data, dict) else None
|
|
1691
|
+
usage = payload.get("data") if payload else None
|
|
1692
|
+
if isinstance(usage, dict):
|
|
1693
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1694
|
+
return {
|
|
1695
|
+
"success": False,
|
|
1696
|
+
"status_code": status,
|
|
1697
|
+
"error_message": "The Org usage endpoint returned an unexpected payload.",
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
message = None
|
|
1701
|
+
if isinstance(data, dict):
|
|
1702
|
+
message = (
|
|
1703
|
+
data.get("message")
|
|
1704
|
+
or data.get("error")
|
|
1705
|
+
or data.get("detail")
|
|
1706
|
+
)
|
|
1707
|
+
return {
|
|
1708
|
+
"success": False,
|
|
1709
|
+
"status_code": status,
|
|
1710
|
+
"error_message": message or f"The Org responded with {status}",
|
|
1711
|
+
}
|
|
1712
|
+
except Exception as exc:
|
|
1713
|
+
logger.error(f"The Org connectivity test failed: {exc}")
|
|
1714
|
+
return {"success": False, "status_code": 0, "error_message": str(exc)}
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
###############################################################################
|
|
1718
|
+
# DATAGMA CONNECTIVITY
|
|
1719
|
+
###############################################################################
|
|
1720
|
+
|
|
1721
|
+
async def test_datagma(api_key: str) -> Dict[str, Any]:
|
|
1722
|
+
"""
|
|
1723
|
+
Connectivity test for Datagma using the documented Get Credit endpoint
|
|
1724
|
+
with query param authentication.
|
|
1725
|
+
|
|
1726
|
+
Endpoint: GET https://gateway.datagma.net/api/ingress/v1/mine?apiId=<KEY>
|
|
1727
|
+
"""
|
|
1728
|
+
base_url = "https://gateway.datagma.net/api/ingress/v1/mine"
|
|
1729
|
+
url = f"{base_url}?apiId={api_key}"
|
|
1730
|
+
|
|
1731
|
+
try:
|
|
1732
|
+
timeout = aiohttp.ClientTimeout(total=10)
|
|
1733
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
1734
|
+
async with session.get(url) as resp:
|
|
1735
|
+
status = resp.status
|
|
1736
|
+
data = await safe_json(resp)
|
|
1737
|
+
|
|
1738
|
+
if status == 200:
|
|
1739
|
+
if isinstance(data, dict) and ("error" in data or "errors" in data):
|
|
1740
|
+
err = data.get("error") or data.get("errors")
|
|
1741
|
+
if isinstance(err, dict):
|
|
1742
|
+
err = err.get("message") or str(err)
|
|
1743
|
+
return {"success": False, "status_code": status, "error_message": str(err)}
|
|
1744
|
+
return {"success": True, "status_code": status, "error_message": None}
|
|
1745
|
+
|
|
1746
|
+
if status in (401, 403):
|
|
1747
|
+
msg = None
|
|
1748
|
+
if isinstance(data, dict):
|
|
1749
|
+
msg = data.get("message") or data.get("error")
|
|
1750
|
+
return {
|
|
1751
|
+
"success": False,
|
|
1752
|
+
"status_code": status,
|
|
1753
|
+
"error_message": msg or "Unauthorized – check Datagma API key",
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
return {
|
|
1757
|
+
"success": False,
|
|
1758
|
+
"status_code": status,
|
|
1759
|
+
"error_message": f"Datagma responded with {status}",
|
|
1760
|
+
}
|
|
1761
|
+
except Exception as e:
|
|
1762
|
+
logger.error(f"Datagma connectivity test failed: {e}")
|
|
1763
|
+
return {"success": False, "status_code": 0, "error_message": str(e)}
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
###############################################################################
|
|
1767
|
+
# MAIN CONNECTIVITY FUNCTION
|
|
1768
|
+
###############################################################################
|
|
1769
|
+
|
|
1770
|
+
async def test_connectivity(tool_config: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
|
1771
|
+
"""
|
|
1772
|
+
Checks connectivity for multiple tools and returns a dictionary
|
|
1773
|
+
with the result for each.
|
|
1774
|
+
|
|
1775
|
+
Special-cases:
|
|
1776
|
+
• 'openai' – needs modelName & reasoningEffort
|
|
1777
|
+
• 'googleworkspace' – needs subjectEmail
|
|
1778
|
+
• 'googledrive' – needs subjectEmail
|
|
1779
|
+
• 'smtpEmail' – has *no* apiKey; instead requires usernames,
|
|
1780
|
+
passwords, smtp/imap hosts & ports
|
|
1781
|
+
"""
|
|
1782
|
+
# Updated test_mapping with the revised test_* functions
|
|
1783
|
+
test_mapping: Dict[str, Callable[..., Awaitable[Dict[str, Any]]]] = {
|
|
1784
|
+
"zerobounce": test_zerobounce,
|
|
1785
|
+
"openai": test_openai,
|
|
1786
|
+
"googleworkspace": test_google_workspace,
|
|
1787
|
+
"googledrive": test_google_drive,
|
|
1788
|
+
"serpapi": test_serpapi,
|
|
1789
|
+
"serperdev": test_serperdev,
|
|
1790
|
+
"proxycurl": test_proxycurl,
|
|
1791
|
+
"exa": test_exa,
|
|
1792
|
+
"apollo": test_apollo,
|
|
1793
|
+
"hubspot": test_hubspot,
|
|
1794
|
+
"github": test_github,
|
|
1795
|
+
"smtpEmail": test_smtp_accounts,
|
|
1796
|
+
"hunter": test_hunter,
|
|
1797
|
+
"findymail": test_findyemail,
|
|
1798
|
+
"datagma": test_datagma,
|
|
1799
|
+
"jinaai": test_jinaai,
|
|
1800
|
+
"firefliesai": test_firefliesai,
|
|
1801
|
+
"firecrawl": test_firecrawl,
|
|
1802
|
+
"youtube": test_youtube,
|
|
1803
|
+
"orum": test_orum,
|
|
1804
|
+
"aircall": test_aircall, # handled specially to pass appId + apiToken
|
|
1805
|
+
"ringover": test_ringover,
|
|
1806
|
+
"dialpad": test_dialpad, # handled specially to pass client credentials
|
|
1807
|
+
"nooks": test_nooks,
|
|
1808
|
+
"commonRoom": test_commonroom,
|
|
1809
|
+
"scarf": test_scarf,
|
|
1810
|
+
"theorg": test_theorg,
|
|
1811
|
+
"salesforce": test_salesforce,
|
|
1812
|
+
"clay": test_clay,
|
|
1813
|
+
"posthog": test_posthog,
|
|
1814
|
+
"mcpServer": test_mcp_server,
|
|
1815
|
+
"slack": test_slack,
|
|
1816
|
+
"mailgun": test_mailgun,
|
|
1817
|
+
"mailreach": test_mailreach,
|
|
1818
|
+
"sendgrid": test_sendgrid,
|
|
1819
|
+
"samgov": test_samgov,
|
|
1820
|
+
"scraperapi": test_scraperapi,
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
results: Dict[str, Dict[str, Any]] = {}
|
|
1824
|
+
|
|
1825
|
+
for tool in tool_config:
|
|
1826
|
+
tool_name: str = tool.get("name", "")
|
|
1827
|
+
config_entries: List[Dict[str, Any]] = tool.get("configuration", [])
|
|
1828
|
+
|
|
1829
|
+
if not tool_name:
|
|
1830
|
+
logger.warning("Tool entry missing 'name' field.")
|
|
1831
|
+
results.setdefault("unknown_tool", {
|
|
1832
|
+
"success": False,
|
|
1833
|
+
"status_code": 0,
|
|
1834
|
+
"error_message": "Tool entry missing 'name'."
|
|
1835
|
+
})
|
|
1836
|
+
continue
|
|
1837
|
+
|
|
1838
|
+
if tool_name not in test_mapping:
|
|
1839
|
+
logger.warning(f"No test function found for tool: {tool_name}")
|
|
1840
|
+
results[tool_name] = {
|
|
1841
|
+
"success": False,
|
|
1842
|
+
"status_code": 0,
|
|
1843
|
+
"error_message": f"No test function for tool '{tool_name}'."
|
|
1844
|
+
}
|
|
1845
|
+
continue
|
|
1846
|
+
|
|
1847
|
+
# ------------------------------------------------------------------ #
|
|
1848
|
+
# Special-case: SMTP / IMAP connectivity (no apiKey)
|
|
1849
|
+
# ------------------------------------------------------------------ #
|
|
1850
|
+
if tool_name == "smtpEmail":
|
|
1851
|
+
def _get(name: str, default: Any = None):
|
|
1852
|
+
return next((c["value"] for c in config_entries if c["name"] == name), default)
|
|
1853
|
+
|
|
1854
|
+
usernames = _get("usernames", "")
|
|
1855
|
+
passwords = _get("passwords", "")
|
|
1856
|
+
smtp_host = _get("smtpEndpoint", "smtp.gmail.com")
|
|
1857
|
+
smtp_port = int(_get("smtpPort", 587))
|
|
1858
|
+
imap_host = _get("imapEndpoint", "imap.gmail.com")
|
|
1859
|
+
imap_port = int(_get("imapPort", 993))
|
|
1860
|
+
|
|
1861
|
+
if not usernames or not passwords:
|
|
1862
|
+
results[tool_name] = {
|
|
1863
|
+
"success": False,
|
|
1864
|
+
"status_code": 0,
|
|
1865
|
+
"error_message": "Missing usernames or passwords."
|
|
1866
|
+
}
|
|
1867
|
+
else:
|
|
1868
|
+
logger.info("Testing connectivity for smtpEmail…")
|
|
1869
|
+
results[tool_name] = await test_smtp_accounts(
|
|
1870
|
+
usernames,
|
|
1871
|
+
passwords,
|
|
1872
|
+
smtp_host,
|
|
1873
|
+
smtp_port,
|
|
1874
|
+
imap_host,
|
|
1875
|
+
imap_port,
|
|
1876
|
+
)
|
|
1877
|
+
continue # handled – move to next tool
|
|
1878
|
+
|
|
1879
|
+
# ------------------------------------------------------------------ #
|
|
1880
|
+
# Special-case: MCP server (headers instead of apiKey)
|
|
1881
|
+
# ------------------------------------------------------------------ #
|
|
1882
|
+
if tool_name == "mcpServer":
|
|
1883
|
+
server_url = next((c["value"] for c in config_entries if c["name"] == "serverUrl"), "")
|
|
1884
|
+
server_label = next((c["value"] for c in config_entries if c["name"] == "serverLabel"), "")
|
|
1885
|
+
header_name = next((c["value"] for c in config_entries if c["name"] == "apiKeyHeaderName"), "")
|
|
1886
|
+
header_value = next((c["value"] for c in config_entries if c["name"] == "apiKeyHeaderValue"), "")
|
|
1887
|
+
if not server_url or not header_name or not header_value:
|
|
1888
|
+
results[tool_name] = {
|
|
1889
|
+
"success": False,
|
|
1890
|
+
"status_code": 0,
|
|
1891
|
+
"error_message": "Missing serverUrl or API key header info.",
|
|
1892
|
+
}
|
|
1893
|
+
else:
|
|
1894
|
+
logger.info("Testing connectivity for mcpServer…")
|
|
1895
|
+
results[tool_name] = await test_mcp_server(server_url, server_label, header_name, header_value)
|
|
1896
|
+
continue
|
|
1897
|
+
|
|
1898
|
+
# ------------------------------------------------------------------ #
|
|
1899
|
+
# Special-case: Slack (webhookUrl instead of an apiKey)
|
|
1900
|
+
# ------------------------------------------------------------------ #
|
|
1901
|
+
if tool_name == "slack":
|
|
1902
|
+
webhook_url = next(
|
|
1903
|
+
(c["value"] for c in config_entries if c["name"] == "webhookUrl"),
|
|
1904
|
+
None
|
|
1905
|
+
)
|
|
1906
|
+
if not webhook_url:
|
|
1907
|
+
results[tool_name] = {
|
|
1908
|
+
"success": False,
|
|
1909
|
+
"status_code": 0,
|
|
1910
|
+
"error_message": "Missing 'webhookUrl' for Slack."
|
|
1911
|
+
}
|
|
1912
|
+
else:
|
|
1913
|
+
logger.info("Testing connectivity for Slack…")
|
|
1914
|
+
results[tool_name] = await test_slack(webhook_url)
|
|
1915
|
+
continue
|
|
1916
|
+
|
|
1917
|
+
# ------------------------------------------------------------------ #
|
|
1918
|
+
# Special-case: Mailgun (needs notifyDomain in addition to apiKey)
|
|
1919
|
+
# ------------------------------------------------------------------ #
|
|
1920
|
+
if tool_name == "mailgun":
|
|
1921
|
+
api_key = next((c["value"] for c in config_entries if c["name"] == "apiKey"), None)
|
|
1922
|
+
# Prefer new field name 'domain', fall back to legacy 'notifyDomain'
|
|
1923
|
+
domain = next((c["value"] for c in config_entries if c["name"] == "domain"), None)
|
|
1924
|
+
if not domain:
|
|
1925
|
+
domain = next((c["value"] for c in config_entries if c["name"] == "notifyDomain"), None)
|
|
1926
|
+
if not api_key or not domain:
|
|
1927
|
+
results[tool_name] = {
|
|
1928
|
+
"success": False,
|
|
1929
|
+
"status_code": 0,
|
|
1930
|
+
"error_message": "Missing apiKey or domain for Mailgun.",
|
|
1931
|
+
}
|
|
1932
|
+
else:
|
|
1933
|
+
logger.info("Testing connectivity for Mailgun…")
|
|
1934
|
+
results[tool_name] = await test_mailgun(api_key, domain)
|
|
1935
|
+
continue
|
|
1936
|
+
|
|
1937
|
+
# ------------------------------------------------------------------ #
|
|
1938
|
+
# Special-case: PostHog (needs host + project id + personal API key)
|
|
1939
|
+
# ------------------------------------------------------------------ #
|
|
1940
|
+
if tool_name == "posthog":
|
|
1941
|
+
api_host = next((c["value"] for c in config_entries if c["name"] == "api_host"), None)
|
|
1942
|
+
project_id = next((c["value"] for c in config_entries if c["name"] == "project_id"), None)
|
|
1943
|
+
personal_api_key = next(
|
|
1944
|
+
(c["value"] for c in config_entries if c["name"] in ("personal_api_key", "personalApiKey")),
|
|
1945
|
+
None,
|
|
1946
|
+
)
|
|
1947
|
+
|
|
1948
|
+
if not api_host or not project_id or not personal_api_key:
|
|
1949
|
+
results[tool_name] = {
|
|
1950
|
+
"success": False,
|
|
1951
|
+
"status_code": 0,
|
|
1952
|
+
"error_message": "Missing api_host, project_id, or personal_api_key for PostHog.",
|
|
1953
|
+
}
|
|
1954
|
+
else:
|
|
1955
|
+
logger.info("Testing connectivity for PostHog…")
|
|
1956
|
+
results[tool_name] = await test_posthog(api_host, project_id, personal_api_key)
|
|
1957
|
+
continue
|
|
1958
|
+
|
|
1959
|
+
# ------------------------------------------------------------------ #
|
|
1960
|
+
# Special-case: Salesforce (requires credentials)
|
|
1961
|
+
# ------------------------------------------------------------------ #
|
|
1962
|
+
if tool_name == "salesforce":
|
|
1963
|
+
cfg_map = {c.get("name"): c.get("value") for c in config_entries if c}
|
|
1964
|
+
username = cfg_map.get("username")
|
|
1965
|
+
password = cfg_map.get("password")
|
|
1966
|
+
security_token = cfg_map.get("security_token")
|
|
1967
|
+
domain = cfg_map.get("domain", "login")
|
|
1968
|
+
client_id = cfg_map.get("client_id")
|
|
1969
|
+
client_secret = cfg_map.get("client_secret")
|
|
1970
|
+
|
|
1971
|
+
if not all([username, password, security_token]):
|
|
1972
|
+
results[tool_name] = {
|
|
1973
|
+
"success": False,
|
|
1974
|
+
"status_code": 0,
|
|
1975
|
+
"error_message": "Missing Salesforce credentials.",
|
|
1976
|
+
}
|
|
1977
|
+
else:
|
|
1978
|
+
logger.info("Testing connectivity for salesforce…")
|
|
1979
|
+
results[tool_name] = await test_salesforce(
|
|
1980
|
+
username,
|
|
1981
|
+
password,
|
|
1982
|
+
security_token,
|
|
1983
|
+
domain,
|
|
1984
|
+
client_id,
|
|
1985
|
+
client_secret,
|
|
1986
|
+
)
|
|
1987
|
+
continue
|
|
1988
|
+
|
|
1989
|
+
# ------------------------------------------------------------------ #
|
|
1990
|
+
# Special-case: Aircall (app_id + api_token)
|
|
1991
|
+
# ------------------------------------------------------------------ #
|
|
1992
|
+
if tool_name == "aircall":
|
|
1993
|
+
app_id = next((c["value"] for c in config_entries if c["name"] == "apiId"), None)
|
|
1994
|
+
api_token = next((c["value"] for c in config_entries if c["name"] == "apiToken"), None)
|
|
1995
|
+
if not app_id or not api_token:
|
|
1996
|
+
results[tool_name] = {
|
|
1997
|
+
"success": False,
|
|
1998
|
+
"status_code": 0,
|
|
1999
|
+
"error_message": "Missing apiId or apiToken for Aircall.",
|
|
2000
|
+
}
|
|
2001
|
+
else:
|
|
2002
|
+
logger.info("Testing connectivity for Aircall…")
|
|
2003
|
+
results[tool_name] = await test_aircall(app_id, api_token)
|
|
2004
|
+
continue
|
|
2005
|
+
|
|
2006
|
+
# ------------------------------------------------------------------ #
|
|
2007
|
+
# Special-case: Dialpad (client credentials)
|
|
2008
|
+
# ------------------------------------------------------------------ #
|
|
2009
|
+
if tool_name == "dialpad":
|
|
2010
|
+
client_id = next((c["value"] for c in config_entries if c["name"] == "clientId"), None)
|
|
2011
|
+
client_secret = next((c["value"] for c in config_entries if c["name"] == "clientSecret"), None)
|
|
2012
|
+
if not client_id or not client_secret:
|
|
2013
|
+
results[tool_name] = {
|
|
2014
|
+
"success": False,
|
|
2015
|
+
"status_code": 0,
|
|
2016
|
+
"error_message": "Missing clientId or clientSecret for Dialpad.",
|
|
2017
|
+
}
|
|
2018
|
+
else:
|
|
2019
|
+
logger.info("Testing connectivity for Dialpad…")
|
|
2020
|
+
results[tool_name] = await test_dialpad(client_id, client_secret)
|
|
2021
|
+
continue
|
|
2022
|
+
|
|
2023
|
+
# ------------------------------------------------------------------ #
|
|
2024
|
+
# All other tools – expect an apiKey by default
|
|
2025
|
+
# ------------------------------------------------------------------ #
|
|
2026
|
+
api_key = next((c["value"] for c in config_entries if c["name"] == "apiKey"), None)
|
|
2027
|
+
if not api_key:
|
|
2028
|
+
logger.warning(f"Tool '{tool_name}' missing 'apiKey' in configuration.")
|
|
2029
|
+
results[tool_name] = {
|
|
2030
|
+
"success": False,
|
|
2031
|
+
"status_code": 0,
|
|
2032
|
+
"error_message": "Missing apiKey."
|
|
2033
|
+
}
|
|
2034
|
+
continue
|
|
2035
|
+
|
|
2036
|
+
logger.info(f"Testing connectivity for {tool_name}…")
|
|
2037
|
+
|
|
2038
|
+
# OpenAI needs extra args
|
|
2039
|
+
if tool_name == "openai":
|
|
2040
|
+
model_name = next((c["value"] for c in config_entries if c["name"] == "modelName"), "gpt-5.1-chat")
|
|
2041
|
+
reasoning_effort = next((c["value"] for c in config_entries if c["name"] == "reasoningEffort"), "medium")
|
|
2042
|
+
results[tool_name] = await test_openai(api_key, model_name, reasoning_effort)
|
|
2043
|
+
|
|
2044
|
+
# Google Workspace needs subjectEmail
|
|
2045
|
+
elif tool_name == "googleworkspace":
|
|
2046
|
+
subject_email = next((c["value"] for c in config_entries if c["name"] == "subjectEmail"), "")
|
|
2047
|
+
if not subject_email:
|
|
2048
|
+
results[tool_name] = {
|
|
2049
|
+
"success": False,
|
|
2050
|
+
"status_code": 0,
|
|
2051
|
+
"error_message": "Missing subjectEmail for Google Workspace."
|
|
2052
|
+
}
|
|
2053
|
+
else:
|
|
2054
|
+
results[tool_name] = await test_google_workspace(api_key, subject_email)
|
|
2055
|
+
|
|
2056
|
+
# Google Drive also needs subjectEmail
|
|
2057
|
+
elif tool_name == "googledrive":
|
|
2058
|
+
subject_email = next((c["value"] for c in config_entries if c["name"] == "subjectEmail"), "")
|
|
2059
|
+
if not subject_email:
|
|
2060
|
+
results[tool_name] = {
|
|
2061
|
+
"success": False,
|
|
2062
|
+
"status_code": 0,
|
|
2063
|
+
"error_message": "Missing subjectEmail for Google Drive.",
|
|
2064
|
+
}
|
|
2065
|
+
else:
|
|
2066
|
+
results[tool_name] = await test_google_drive(api_key, subject_email)
|
|
2067
|
+
|
|
2068
|
+
# Clay needs webhook URL in addition to apiKey
|
|
2069
|
+
elif tool_name == "clay":
|
|
2070
|
+
webhook = next(
|
|
2071
|
+
(c["value"] for c in config_entries if c["name"] in ("webhook", "webhookUrl", "webhook_url")),
|
|
2072
|
+
None,
|
|
2073
|
+
)
|
|
2074
|
+
if not webhook:
|
|
2075
|
+
results[tool_name] = {
|
|
2076
|
+
"success": False,
|
|
2077
|
+
"status_code": 0,
|
|
2078
|
+
"error_message": "Missing webhook URL for Clay.",
|
|
2079
|
+
}
|
|
2080
|
+
else:
|
|
2081
|
+
results[tool_name] = await test_clay(api_key, webhook)
|
|
2082
|
+
|
|
2083
|
+
# Everything else calls the mapped test function with just api_key
|
|
2084
|
+
else:
|
|
2085
|
+
results[tool_name] = await test_mapping[tool_name](api_key)
|
|
2086
|
+
|
|
2087
|
+
return results
|