dhisana 0.0.1.dev116__py3-none-any.whl → 0.0.1.dev236__py3-none-any.whl

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