dhisana 0.0.1.dev233__py3-none-any.whl → 0.0.1.dev235__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/schemas/common.py CHANGED
@@ -392,6 +392,7 @@ class ReplyEmailContext(BaseModel):
392
392
  reply_body: str
393
393
  sender_email: str
394
394
  sender_name: str
395
+ fallback_recipient: Optional[str] = None
395
396
  mark_as_read: str = "True"
396
397
  add_labels: Optional[List[str]] = None
397
398
  reply_body_format: BodyFormat = BodyFormat.AUTO
@@ -267,15 +267,45 @@ async def reply_to_email_google_oauth_async(
267
267
  _rethrow_with_google_message(exc, "Gmail Fetch Message OAuth")
268
268
 
269
269
  headers_list = (original.get("payload") or {}).get("headers", [])
270
- headers_map = {h.get("name"): h.get("value") for h in headers_list if isinstance(h, dict)}
271
- thread_id = original.get("threadId")
272
-
273
- subject = headers_map.get("Subject", "") or ""
270
+ # Use case-insensitive lookups via find_header to avoid missing values on header casing differences.
271
+ subject = find_header(headers_list, "Subject") or ""
274
272
  if not subject.startswith("Re:"):
275
273
  subject = f"Re: {subject}"
276
- to_addresses = headers_map.get("From", "") or ""
277
- cc_addresses = headers_map.get("Cc", "") or ""
278
- message_id_header = headers_map.get("Message-ID", "") or ""
274
+ reply_to_header = find_header(headers_list, "Reply-To") or ""
275
+ from_header = find_header(headers_list, "From") or ""
276
+ to_header = find_header(headers_list, "To") or ""
277
+ cc_header = find_header(headers_list, "Cc") or ""
278
+ message_id_header = find_header(headers_list, "Message-ID") or ""
279
+ thread_id = original.get("threadId")
280
+
281
+ sender_email_lc = (reply_email_context.sender_email or "").lower()
282
+
283
+ def _is_self(addr: str) -> bool:
284
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
285
+
286
+ cc_addresses = cc_header or ""
287
+ # Prefer Reply-To unless it points back to the sender. If the original was SENT mail,
288
+ # From will equal the sender, so we should reply to the original To/CC instead.
289
+ if reply_to_header and not _is_self(reply_to_header):
290
+ to_addresses = reply_to_header
291
+ elif from_header and not _is_self(from_header):
292
+ to_addresses = from_header
293
+ elif to_header and not _is_self(to_header):
294
+ to_addresses = to_header
295
+ else:
296
+ combined = ", ".join([v for v in (to_header, cc_header, from_header) if v])
297
+ to_addresses = combined
298
+ cc_addresses = ""
299
+
300
+ if (not to_addresses or _is_self(to_addresses)) and reply_email_context.fallback_recipient:
301
+ if not _is_self(reply_email_context.fallback_recipient):
302
+ to_addresses = reply_email_context.fallback_recipient
303
+ cc_addresses = ""
304
+
305
+ if not to_addresses or _is_self(to_addresses):
306
+ raise ValueError(
307
+ "No valid recipient found in the original message; refusing to reply to sender."
308
+ )
279
309
 
280
310
  # 2) Build reply MIME
281
311
  plain_reply, html_reply, resolved_reply_fmt = body_variants(
@@ -895,17 +895,43 @@ async def reply_to_email_async(
895
895
  original_message = response.json()
896
896
 
897
897
  headers_list = original_message.get('payload', {}).get('headers', [])
898
- headers_dict = {h['name']: h['value'] for h in headers_list}
899
- thread_id = original_message.get('threadId')
900
-
901
- # 2. Prepare reply headers
902
- subject = headers_dict.get('Subject', '')
898
+ # Case-insensitive header lookup and resilient recipient fallback to avoid Gmail 400s.
899
+ subject = find_header(headers_list, 'Subject') or ''
903
900
  if not subject.startswith('Re:'):
904
901
  subject = f'Re: {subject}'
902
+ reply_to_header = find_header(headers_list, 'Reply-To') or ''
903
+ from_header = find_header(headers_list, 'From') or ''
904
+ to_header = find_header(headers_list, 'To') or ''
905
+ cc_header = find_header(headers_list, 'Cc') or ''
906
+ message_id_header = find_header(headers_list, 'Message-ID') or ''
907
+ thread_id = original_message.get('threadId')
908
+
909
+ sender_email_lc = (reply_email_context.sender_email or '').lower()
910
+
911
+ def _is_self(addr: str) -> bool:
912
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
905
913
 
906
- to_addresses = headers_dict.get('From', '')
907
- cc_addresses = headers_dict.get('Cc', '')
908
- message_id_header = headers_dict.get('Message-ID', '')
914
+ cc_addresses = cc_header or ''
915
+ if reply_to_header and not _is_self(reply_to_header):
916
+ to_addresses = reply_to_header
917
+ elif from_header and not _is_self(from_header):
918
+ to_addresses = from_header
919
+ elif to_header and not _is_self(to_header):
920
+ to_addresses = to_header
921
+ else:
922
+ combined = ", ".join([v for v in (to_header, cc_header, from_header) if v])
923
+ to_addresses = combined
924
+ cc_addresses = ''
925
+
926
+ if (not to_addresses or _is_self(to_addresses)) and reply_email_context.fallback_recipient:
927
+ if not _is_self(reply_email_context.fallback_recipient):
928
+ to_addresses = reply_email_context.fallback_recipient
929
+ cc_addresses = ''
930
+
931
+ if not to_addresses or _is_self(to_addresses):
932
+ raise ValueError(
933
+ "No valid recipient found in the original message; refusing to reply to sender."
934
+ )
909
935
 
910
936
  # 3. Create the reply email message
911
937
  plain_reply, html_reply, resolved_reply_fmt = body_variants(
@@ -351,10 +351,50 @@ async def reply_to_email_m365_async(
351
351
  orig_subject = orig.get("subject", "")
352
352
  subject = orig_subject if orig_subject.startswith("Re:") else f"Re: {orig_subject}"
353
353
  thread_id = orig.get("conversationId", "")
354
- from_addr = orig.get("from", {}).get("emailAddress", {})
355
- to_addresses = from_addr.get("address", "")
356
354
  cc_list = orig.get("ccRecipients", [])
357
- cc_addresses = ", ".join([(r.get("emailAddress", {}) or {}).get("address", "") for r in cc_list if r])
355
+ to_list = orig.get("toRecipients", [])
356
+ sender_email_lc = (reply_email_context.sender_email or "").lower()
357
+
358
+ def _is_self(addr: str) -> bool:
359
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
360
+
361
+ def _addresses(recipients: List[Dict[str, Any]]) -> List[str]:
362
+ return [
363
+ (recipient.get("emailAddress", {}) or {}).get("address", "")
364
+ for recipient in recipients
365
+ if recipient
366
+ ]
367
+
368
+ to_addresses = ", ".join(
369
+ [addr for addr in _addresses(to_list) if addr and not _is_self(addr)]
370
+ )
371
+ cc_addresses = ", ".join(
372
+ [addr for addr in _addresses(cc_list) if addr and not _is_self(addr)]
373
+ )
374
+
375
+ all_recipients = [addr for addr in _addresses(to_list + cc_list) if addr]
376
+ if not any(all_recipients):
377
+ from_addr = orig.get("from", {}).get("emailAddress", {})
378
+ from_address = from_addr.get("address", "")
379
+ if from_address:
380
+ all_recipients.append(from_address)
381
+
382
+ non_self_recipients = [addr for addr in all_recipients if not _is_self(addr)]
383
+ if not non_self_recipients and reply_email_context.fallback_recipient:
384
+ fr = reply_email_context.fallback_recipient
385
+ if fr and not _is_self(fr):
386
+ non_self_recipients.append(fr)
387
+
388
+ if not to_addresses and non_self_recipients:
389
+ to_addresses = ", ".join(non_self_recipients)
390
+ cc_addresses = ""
391
+
392
+ if not non_self_recipients:
393
+ raise httpx.HTTPStatusError(
394
+ "No valid recipient found in the original message; refusing to reply to sender.",
395
+ request=get_resp.request,
396
+ response=get_resp,
397
+ )
358
398
 
359
399
  # 2) Create reply-all draft with comment
360
400
  create_reply_url = (
@@ -394,17 +394,99 @@ async def reply_to_email_via_smtp_async(
394
394
  )
395
395
  try:
396
396
  conn.login(username, password)
397
- conn.select(mailbox, readonly=False) # can set flags if needed
398
- # Search for the exact Message-ID header
399
- status, nums = conn.search(None, "HEADER", "Message-ID", ctx.message_id)
400
- if status != "OK" or not nums[0]:
401
- logging.warning("IMAP search for %r returned %s", ctx.message_id, nums)
397
+ # Sent messages usually live outside INBOX; build a candidate list
398
+ # from the provided mailbox, common sent folders, and any LISTed
399
+ # mailboxes containing "sent" (case-insensitive).
400
+ candidate_mailboxes = []
401
+ if mailbox:
402
+ candidate_mailboxes.append(mailbox)
403
+ candidate_mailboxes.extend([
404
+ "Sent",
405
+ "Sent Items",
406
+ "Sent Mail",
407
+ "[Gmail]/Sent Mail",
408
+ "[Gmail]/Sent Items",
409
+ "INBOX.Sent",
410
+ "INBOX/Sent",
411
+ ])
412
+ try:
413
+ status, mailboxes = conn.list()
414
+ if status == "OK" and mailboxes:
415
+ for mbox in mailboxes:
416
+ try:
417
+ decoded = mbox.decode(errors="ignore")
418
+ except Exception:
419
+ decoded = str(mbox)
420
+ # Parse flags + name from LIST response:
421
+ # e.g., (\\HasNoChildren \\Sent) "/" "Sent Items"
422
+ flags = set()
423
+ name_part = decoded
424
+ if ") " in decoded:
425
+ flags_raw, _, remainder = decoded.partition(") ")
426
+ flags = {f.lower() for f in flags_raw.strip("(").split() if f}
427
+ # remainder is like '"/" "Sent Items"' or '"/" Sent'
428
+ pieces = remainder.split(" ", 1)
429
+ if len(pieces) == 2:
430
+ name_part = pieces[1].strip()
431
+ else:
432
+ name_part = remainder.strip()
433
+ name_part = name_part.strip()
434
+ if name_part.startswith('"') and name_part.endswith('"'):
435
+ name_part = name_part[1:-1]
436
+
437
+ # Prefer provider-marked \Sent flag; otherwise fall back to substring match.
438
+ is_sent_flag = "\\sent" in flags
439
+ is_sent_name = "sent" in name_part.lower()
440
+ if is_sent_flag or is_sent_name:
441
+ candidate_mailboxes.append(name_part)
442
+ except Exception:
443
+ logging.exception("IMAP LIST failed; continuing with default sent folders")
444
+ # Deduplicate while preserving order
445
+ seen = set()
446
+ candidate_mailboxes = [m for m in candidate_mailboxes if not (m in seen or seen.add(m))]
447
+
448
+ msg_data = None
449
+ for mb in candidate_mailboxes:
450
+ def _try_select(name: str) -> bool:
451
+ # Quote mailbox names with spaces or special chars; fall back to raw.
452
+ for candidate in (f'"{name}"', name):
453
+ try:
454
+ status, _ = conn.select(candidate, readonly=False)
455
+ except imaplib.IMAP4.error as exc:
456
+ logging.warning("IMAP select %r failed: %s", candidate, exc)
457
+ continue
458
+ except Exception as exc:
459
+ logging.warning("IMAP select %r failed: %s", candidate, exc)
460
+ continue
461
+ if status == "OK":
462
+ return True
463
+ return False
464
+
465
+ if not _try_select(mb):
466
+ continue
467
+ # Search for the Message-ID header. Some servers store IDs without angle
468
+ # brackets or require quoted search terms, so try a few variants.
469
+ candidates = [ctx.message_id]
470
+ trimmed = ctx.message_id.strip()
471
+ if trimmed.startswith("<") and trimmed.endswith(">"):
472
+ candidates.append(trimmed[1:-1])
473
+ for mid in candidates:
474
+ status, nums = conn.search(None, "HEADER", "Message-ID", f'"{mid}"')
475
+ if status == "OK" and nums and nums[0]:
476
+ num = nums[0].split()[0]
477
+ _, data = conn.fetch(num, "(RFC822)")
478
+ if ctx.mark_as_read.lower() == "true":
479
+ conn.store(num, "+FLAGS", "\\Seen")
480
+ msg_data = data[0][1] if data and data[0] else None
481
+ break
482
+ if msg_data:
483
+ break
484
+
485
+ if not msg_data:
486
+ logging.warning("IMAP search for %r returned no matches in any mailbox", ctx.message_id)
402
487
  return None
403
- num = nums[0].split()[0]
404
- _, data = conn.fetch(num, "(RFC822)")
405
- if ctx.mark_as_read.lower() == "true":
406
- conn.store(num, "+FLAGS", "\\Seen")
407
- return data[0][1] if data and data[0] else None
488
+
489
+ return msg_data
408
490
  finally:
409
491
  try:
410
492
  conn.close()
@@ -422,6 +504,17 @@ async def reply_to_email_via_smtp_async(
422
504
  # 2. Derive reply headers
423
505
  to_addrs = hdr("Reply-To") or hdr("From")
424
506
  cc_addrs = hdr("Cc")
507
+ # If the derived recipient points back to the sender or is missing, fall back to provided recipient.
508
+ sender_email_lc = (ctx.sender_email or "").lower()
509
+ def _is_self(addr: str) -> bool:
510
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
511
+ if (not to_addrs or _is_self(to_addrs)) and getattr(ctx, "fallback_recipient", None):
512
+ fr = ctx.fallback_recipient
513
+ if fr and not _is_self(fr):
514
+ to_addrs = fr
515
+ cc_addrs = ""
516
+ if not to_addrs or _is_self(to_addrs):
517
+ raise RuntimeError("No valid recipient found in original message; refusing to reply to sender.")
425
518
  subject = hdr("Subject")
426
519
  if not subject.lower().startswith("re:"):
427
520
  subject = f"Re: {subject}"
@@ -1263,6 +1263,41 @@ async def test_firecrawl(api_key: str) -> Dict[str, Any]:
1263
1263
  return {"success": False, "status_code": 0, "error_message": str(exc)}
1264
1264
 
1265
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
+
1266
1301
  async def test_youtube(api_key: str) -> Dict[str, Any]:
1267
1302
  """
1268
1303
  Tests YouTube Data API v3 by making a simple search request.
@@ -1341,6 +1376,276 @@ async def test_youtube(api_key: str) -> Dict[str, Any]:
1341
1376
  return {"success": False, "status_code": 0, "error_message": str(exc)}
1342
1377
 
1343
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
+
1392
+ try:
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)
1397
+
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",
1518
+ }
1519
+ async with session.get(whoami_url, headers=headers) as response:
1520
+ status = response.status
1521
+ data = await safe_json(response)
1522
+
1523
+ if status == 200:
1524
+ return {"success": True, "status_code": status, "error_message": None}
1525
+
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",
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
+
1344
1649
  ###############################################################################
1345
1650
  # DATAGMA CONNECTIVITY
1346
1651
  ###############################################################################
@@ -1427,6 +1732,13 @@ async def test_connectivity(tool_config: List[Dict[str, Any]]) -> Dict[str, Dict
1427
1732
  "firefliesai": test_firefliesai,
1428
1733
  "firecrawl": test_firecrawl,
1429
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,
1430
1742
  "salesforce": test_salesforce,
1431
1743
  "clay": test_clay,
1432
1744
  "posthog": test_posthog,
@@ -1435,6 +1747,7 @@ async def test_connectivity(tool_config: List[Dict[str, Any]]) -> Dict[str, Dict
1435
1747
  "mailgun": test_mailgun,
1436
1748
  "sendgrid": test_sendgrid,
1437
1749
  "samgov": test_samgov,
1750
+ "scraperapi": test_scraperapi,
1438
1751
  }
1439
1752
 
1440
1753
  results: Dict[str, Dict[str, Any]] = {}
@@ -1603,6 +1916,40 @@ async def test_connectivity(tool_config: List[Dict[str, Any]]) -> Dict[str, Dict
1603
1916
  )
1604
1917
  continue
1605
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
+
1606
1953
  # ------------------------------------------------------------------ #
1607
1954
  # All other tools – expect an apiKey by default
1608
1955
  # ------------------------------------------------------------------ #
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dhisana
3
- Version: 0.0.1.dev233
3
+ Version: 0.0.1.dev235
4
4
  Summary: A Python SDK for Dhisana AI Platform
5
5
  Home-page: https://github.com/dhisana-ai/dhisana-python-sdk
6
6
  Author: Admin
@@ -5,7 +5,7 @@ dhisana/cli/datasets.py,sha256=OwzoCrVQqmh0pKpUAKAg_w9uGYncbWU7ZrAL_QukxAk,839
5
5
  dhisana/cli/models.py,sha256=IzUFZW_X32mL3fpM1_j4q8AF7v5nrxJcxBoqvG-TTgA,706
6
6
  dhisana/cli/predictions.py,sha256=VYgoLK1Ksv6MFImoYZqjQJkds7e5Hso65dHwbxTNNzE,646
7
7
  dhisana/schemas/__init__.py,sha256=jv2YF__bseklT3OWEzlqJ5qE24c4aWd5F4r0TTjOrWQ,65
8
- dhisana/schemas/common.py,sha256=SI6nldyfvZhNhfJwy2Qo1VJ7xwuAlg4QHYGLWPoVvZo,9287
8
+ dhisana/schemas/common.py,sha256=rt1ho4nzVhTwTQ_1Kx5TI-xZSbnyDpYN0fQ8Fgf8z6k,9332
9
9
  dhisana/schemas/sales.py,sha256=k-ZTB-DaQbjvI882L6443H4gspWBFY-VrY2_1xlLn74,33587
10
10
  dhisana/ui/__init__.py,sha256=jv2YF__bseklT3OWEzlqJ5qE24c4aWd5F4r0TTjOrWQ,65
11
11
  dhisana/ui/components.py,sha256=4NXrAyl9tx2wWwoVYyABO-EOGnreGMvql1AkXWajIIo,14316
@@ -46,15 +46,15 @@ dhisana/utils/generate_linkedin_connect_message.py,sha256=WZThEun-DMuAOqlzMI--hG
46
46
  dhisana/utils/generate_linkedin_response_message.py,sha256=-jg-u5Ipf4-cn9q0yjEHsEBe1eJhYLCLrjZDtOXnCyQ,14464
47
47
  dhisana/utils/generate_structured_output_internal.py,sha256=DmZ5QzW-79Jo3JL5nDCZQ-Fjl8Nz7FHK6S0rZxXbKyg,20705
48
48
  dhisana/utils/google_custom_search.py,sha256=5rQ4uAF-hjFpd9ooJkd6CjRvSmhZHhqM0jfHItsbpzk,10071
49
- dhisana/utils/google_oauth_tools.py,sha256=pN5YGkM50OieCFpz9RlmEwfrnGzkh342e0h5XschuuE,26211
50
- dhisana/utils/google_workspace_tools.py,sha256=wyBy5WN3-eUCrKz1HYr_CS0vdsiQgOA-SFb368jSDrY,46957
49
+ dhisana/utils/google_oauth_tools.py,sha256=sxWZLHMfFSF4Wyu-FxQKQiDKDHe0Kl_rRk7D6ejBLYg,27609
50
+ dhisana/utils/google_workspace_tools.py,sha256=pvO1rtDpknHAO9bmBKJ9Zhvrv65Og3U2x20W1ytql08,48185
51
51
  dhisana/utils/hubspot_clearbit.py,sha256=keNX1F_RnDl9AOPxYEOTMdukV_A9g8v9j1fZyT4tuP4,3440
52
52
  dhisana/utils/hubspot_crm_tools.py,sha256=lbXFCeq690_TDLjDG8Gm5E-2f1P5EuDqNf5j8PYpMm8,99298
53
53
  dhisana/utils/instantly_tools.py,sha256=hhqjDPyLE6o0dzzuvryszbK3ipnoGU2eBm6NlsUGJjY,4771
54
54
  dhisana/utils/linkedin_crawler.py,sha256=6fMQTY5lTw2kc65SFHgOAM6YfezAS0Yhg-jkiX8LGHo,6533
55
55
  dhisana/utils/lusha_tools.py,sha256=MdiWlxBBjSNpSKz8rhNOyLPtbeh-YWHgGiUq54vN_gM,12734
56
56
  dhisana/utils/mailgun_tools.py,sha256=qUD-jFMZpmkkkKtyihVSe9tgFzYe-UiiBDHQKtsLq0M,5284
57
- dhisana/utils/microsoft365_tools.py,sha256=AwWSdE-xeHkCx9T_cVgDbzBmsz7Co4KE45rAmt_lnAc,16723
57
+ dhisana/utils/microsoft365_tools.py,sha256=aNIUBBz56HhvnEd0ZMy5EGAtsXcBJ_VOMO5Yy4dyojQ,18289
58
58
  dhisana/utils/openai_assistant_and_file_utils.py,sha256=-eyPcxFvtS-DDtYQGle1SU6C6CuxjulVIojFy27HeWc,8957
59
59
  dhisana/utils/openai_helpers.py,sha256=ZK9S5-jcLCpiiD6XBLkCqYcNz-AGYmO9xh4e2H-FDLo,40155
60
60
  dhisana/utils/openapi_spec_to_tools.py,sha256=oBLVq3WeDWvW9O02NCvY8bxQURQdKwHJHGcX8bC_b2I,1926
@@ -78,8 +78,8 @@ dhisana/utils/serpapi_search_tools.py,sha256=xiiYi6Rd6Mqn94mjSKEs5nNZk1l2-PW_hTL
78
78
  dhisana/utils/serperdev_google_jobs.py,sha256=m5_2f_5y79FOFZz1A_go6m0hIUfbbAoZ0YTjUMO2BSI,4508
79
79
  dhisana/utils/serperdev_local_business.py,sha256=JoZfTg58Hojv61cyuwA2lcnPdLT1lawnWaBNrUYWnuQ,6447
80
80
  dhisana/utils/serperdev_search.py,sha256=_iBKIfHMq4gFv5StYz58eArriygoi1zW6VnLlux8vto,9363
81
- dhisana/utils/smtp_email_tools.py,sha256=tF6GoNqkS9pWP52VTTrYSgL7wPdIp3XTklxrHLdzU5o,17186
82
- dhisana/utils/test_connect.py,sha256=MSF0x_bZm9xL5rQixIFXaB1L7x9SIhtdhT5wMeQM_aA,69087
81
+ dhisana/utils/smtp_email_tools.py,sha256=_1FoN6e-rgkjAKnCVym_IvihJFKz_dOo-43iM6CVqhA,21855
82
+ dhisana/utils/test_connect.py,sha256=aQjPIKevMF_c-wd4Te2UtPpaY-dEa9PVp6MsNCjQ7q8,83667
83
83
  dhisana/utils/trasform_json.py,sha256=7V72XNDpuxUX0GHN5D83z4anj_gIf5zabaHeQm7b1_E,6979
84
84
  dhisana/utils/web_download_parse_tools.py,sha256=ouXwH7CmjcRjoBfP5BWat86MvcGO-8rLCmWQe_eZKjc,7810
85
85
  dhisana/utils/workflow_code_model.py,sha256=YPWse5vBb3O6Km2PvKh1Q3AB8qBkzLt1CrR5xOL9Mro,99
@@ -93,8 +93,8 @@ dhisana/workflow/agent.py,sha256=esv7_i_XuMkV2j1nz_UlsHov_m6X5WZZiZm_tG4OBHU,565
93
93
  dhisana/workflow/flow.py,sha256=xWE3qQbM7j2B3FH8XnY3zOL_QXX4LbTW4ArndnEYJE0,1638
94
94
  dhisana/workflow/task.py,sha256=HlWz9mtrwLYByoSnePOemBUBrMEcj7KbgNjEE1oF5wo,1830
95
95
  dhisana/workflow/test.py,sha256=E7lRnXK0PguTNzyasHytLzTJdkqIPxG5_4qk4hMEeKc,3399
96
- dhisana-0.0.1.dev233.dist-info/METADATA,sha256=9WZyBULJfdKuyYQQCJ6-H3PbFp51F_mkgjtWvpD4UIU,1190
97
- dhisana-0.0.1.dev233.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
98
- dhisana-0.0.1.dev233.dist-info/entry_points.txt,sha256=jujxteZmNI9EkEaK-pOCoWuBujU8TCevdkfl9ZcKHek,49
99
- dhisana-0.0.1.dev233.dist-info/top_level.txt,sha256=NETTHt6YifG_P7XtRHbQiXZlgSFk9Qh9aR-ng1XTf4s,8
100
- dhisana-0.0.1.dev233.dist-info/RECORD,,
96
+ dhisana-0.0.1.dev235.dist-info/METADATA,sha256=FUmdIhxgFjRKEpg8NjqFPuJOVkVhuEi0GcEn47hztGU,1190
97
+ dhisana-0.0.1.dev235.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
98
+ dhisana-0.0.1.dev235.dist-info/entry_points.txt,sha256=jujxteZmNI9EkEaK-pOCoWuBujU8TCevdkfl9ZcKHek,49
99
+ dhisana-0.0.1.dev235.dist-info/top_level.txt,sha256=NETTHt6YifG_P7XtRHbQiXZlgSFk9Qh9aR-ng1XTf4s,8
100
+ dhisana-0.0.1.dev235.dist-info/RECORD,,