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
@@ -0,0 +1,721 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+ import re
5
+ from email.mime.multipart import MIMEMultipart
6
+ from email.mime.text import MIMEText
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import httpx
10
+
11
+ from dhisana.schemas.common import (
12
+ SendEmailContext,
13
+ QueryEmailContext,
14
+ ReplyEmailContext,
15
+ )
16
+ from dhisana.schemas.sales import MessageItem
17
+ from dhisana.utils.email_parse_helpers import (
18
+ find_header,
19
+ parse_single_address,
20
+ find_all_recipients_in_headers,
21
+ convert_date_to_iso,
22
+ extract_email_body_in_plain_text,
23
+ )
24
+ from dhisana.utils.assistant_tool_tag import assistant_tool
25
+ from dhisana.utils.cache_output_tools import retrieve_output, cache_output
26
+ from dhisana.utils.email_body_utils import body_variants
27
+ from typing import Optional as _Optional # avoid name clash in wrappers
28
+
29
+ def _status_phrase(code: int) -> str:
30
+ mapping = {
31
+ 400: "Bad Request",
32
+ 401: "Unauthorized",
33
+ 403: "Forbidden",
34
+ 404: "Not Found",
35
+ 405: "Method Not Allowed",
36
+ 409: "Conflict",
37
+ 412: "Precondition Failed",
38
+ 415: "Unsupported Media Type",
39
+ 429: "Too Many Requests",
40
+ 500: "Internal Server Error",
41
+ 502: "Bad Gateway",
42
+ 503: "Service Unavailable",
43
+ 504: "Gateway Timeout",
44
+ }
45
+ return mapping.get(code, "HTTP Error")
46
+
47
+
48
+ def _extract_google_api_message(response: Optional[httpx.Response]) -> str:
49
+ """Extract a concise message from Google-style error JSON responses."""
50
+ if not response:
51
+ return ""
52
+ try:
53
+ data = response.json()
54
+ except Exception:
55
+ text = getattr(response, "text", None)
56
+ return text or ""
57
+
58
+ msg = None
59
+ if isinstance(data, dict):
60
+ err = data.get("error")
61
+ if isinstance(err, dict):
62
+ msg = err.get("message") or err.get("status")
63
+ elif isinstance(err, str):
64
+ # Some endpoints return string error + error_description
65
+ msg = data.get("error_description") or err
66
+ if not msg:
67
+ msg = data.get("message") or data.get("text")
68
+ return msg or ""
69
+
70
+
71
+ def _rethrow_with_google_message(exc: httpx.HTTPStatusError, context: str) -> None:
72
+ resp = getattr(exc, "response", None)
73
+ code = getattr(resp, "status_code", None) or 0
74
+ phrase = _status_phrase(int(code))
75
+ api_msg = _extract_google_api_message(resp) or "Google API request failed."
76
+ raise httpx.HTTPStatusError(
77
+ f"{code} {phrase} ({context}). {api_msg}", request=exc.request, response=resp
78
+ )
79
+
80
+
81
+ def get_google_access_token(tool_config: Optional[List[Dict]] = None) -> str:
82
+ """
83
+ Retrieve a Google OAuth2 access token from the 'google' integration config.
84
+
85
+ Expected tool_config shape:
86
+ {
87
+ "name": "google",
88
+ "configuration": [
89
+ {"name": "oauth_tokens", "value": {"access_token": "..."} }
90
+ # or {"name": "access_token", "value": "..."}
91
+ ]
92
+ }
93
+
94
+ If provided as a JSON string under oauth_tokens, it is parsed.
95
+ """
96
+ access_token: Optional[str] = None
97
+
98
+ if tool_config:
99
+ g_cfg = next((c for c in tool_config if c.get("name") == "google"), None)
100
+ if g_cfg:
101
+ cfg_map = {f["name"]: f.get("value") for f in g_cfg.get("configuration", []) if f}
102
+ raw_oauth = cfg_map.get("oauth_tokens")
103
+ # oauth_tokens might be a JSON string or a dict
104
+ if isinstance(raw_oauth, str):
105
+ try:
106
+ raw_oauth = json.loads(raw_oauth)
107
+ except Exception:
108
+ raw_oauth = None
109
+ if isinstance(raw_oauth, dict):
110
+ access_token = raw_oauth.get("access_token") or raw_oauth.get("token")
111
+ if not access_token:
112
+ access_token = cfg_map.get("access_token")
113
+
114
+ if not access_token:
115
+ raise ValueError(
116
+ "Google integration is not configured. Please connect Google and supply an OAuth access token."
117
+ )
118
+ return access_token
119
+
120
+
121
+ async def send_email_using_google_oauth_async(
122
+ send_email_context: SendEmailContext,
123
+ tool_config: Optional[List[Dict]] = None,
124
+ ) -> str:
125
+ """
126
+ Send an email using Gmail API with a per-user OAuth2 token.
127
+
128
+ Returns the Gmail message id of the sent message when available.
129
+ """
130
+ token = get_google_access_token(tool_config)
131
+
132
+ plain_body, html_body, resolved_fmt = body_variants(
133
+ send_email_context.body,
134
+ getattr(send_email_context, "body_format", None),
135
+ )
136
+ # Use multipart/alternative when we have both; fall back to single part for pure text.
137
+ if resolved_fmt == "text":
138
+ message = MIMEText(plain_body, "plain", _charset="utf-8")
139
+ else:
140
+ message = MIMEMultipart("alternative")
141
+ message.attach(MIMEText(plain_body, "plain", _charset="utf-8"))
142
+ message.attach(MIMEText(html_body, "html", _charset="utf-8"))
143
+
144
+ message["to"] = send_email_context.recipient
145
+ message["from"] = f"{send_email_context.sender_name} <{send_email_context.sender_email}>"
146
+ message["subject"] = send_email_context.subject
147
+
148
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
149
+
150
+ payload: Dict[str, Any] = {"raw": raw_message}
151
+ if send_email_context.labels:
152
+ payload["labelIds"] = send_email_context.labels
153
+
154
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
155
+ url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
156
+
157
+ async with httpx.AsyncClient(timeout=30) as client:
158
+ try:
159
+ resp = await client.post(url, headers=headers, json=payload)
160
+ resp.raise_for_status()
161
+ data = resp.json() or {}
162
+ return data.get("id", "")
163
+ except httpx.HTTPStatusError as exc:
164
+ _rethrow_with_google_message(exc, "Gmail Send OAuth")
165
+
166
+
167
+ async def list_emails_in_time_range_google_oauth_async(
168
+ context: QueryEmailContext,
169
+ tool_config: Optional[List[Dict]] = None,
170
+ ) -> List[MessageItem]:
171
+ """
172
+ List Gmail messages for the connected user in a time range using OAuth2.
173
+ Returns a list of MessageItem.
174
+ """
175
+ if context.labels is None:
176
+ context.labels = []
177
+
178
+ token = get_google_access_token(tool_config)
179
+ base_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages"
180
+ headers = {"Authorization": f"Bearer {token}"}
181
+
182
+ # Convert RFC3339 times to unix timestamps for Gmail search query
183
+ # Expecting context.start_time and context.end_time as ISO 8601; Gmail q uses epoch seconds
184
+ from datetime import datetime
185
+ start_dt = datetime.fromisoformat(context.start_time.replace("Z", "+00:00"))
186
+ end_dt = datetime.fromisoformat(context.end_time.replace("Z", "+00:00"))
187
+ after_ts = int(start_dt.timestamp())
188
+ before_ts = int(end_dt.timestamp())
189
+
190
+ q_parts: List[str] = [f"after:{after_ts}", f"before:{before_ts}"]
191
+ if context.unread_only:
192
+ q_parts.append("is:unread")
193
+ if context.labels:
194
+ q_parts.extend([f"label:{lbl}" for lbl in context.labels])
195
+ query = " ".join(q_parts)
196
+
197
+ params = {"q": query, "maxResults": 100}
198
+
199
+ items: List[MessageItem] = []
200
+ max_fetch = 500 # defensive cap to avoid excessive paging
201
+ async with httpx.AsyncClient(timeout=30) as client:
202
+ try:
203
+ next_page_token = None
204
+ while True:
205
+ page_params = dict(params)
206
+ if next_page_token:
207
+ page_params["pageToken"] = next_page_token
208
+
209
+ list_resp = await client.get(base_url, headers=headers, params=page_params)
210
+ list_resp.raise_for_status()
211
+ list_data = list_resp.json() or {}
212
+ for m in list_data.get("messages", []) or []:
213
+ if len(items) >= max_fetch:
214
+ break
215
+ mid = m.get("id")
216
+ tid = m.get("threadId")
217
+ if not mid:
218
+ continue
219
+ get_url = f"{base_url}/{mid}"
220
+ get_resp = await client.get(get_url, headers=headers)
221
+ get_resp.raise_for_status()
222
+ mdata = get_resp.json() or {}
223
+
224
+ headers_list = (mdata.get("payload") or {}).get("headers", [])
225
+ from_header = find_header(headers_list, "From") or ""
226
+ subject_header = find_header(headers_list, "Subject") or ""
227
+ date_header = find_header(headers_list, "Date") or ""
228
+
229
+ iso_dt = convert_date_to_iso(date_header)
230
+ s_name, s_email = parse_single_address(from_header)
231
+ r_name, r_email = find_all_recipients_in_headers(headers_list)
232
+
233
+ items.append(
234
+ MessageItem(
235
+ message_id=mdata.get("id", ""),
236
+ thread_id=tid or "",
237
+ sender_name=s_name,
238
+ sender_email=s_email,
239
+ receiver_name=r_name,
240
+ receiver_email=r_email,
241
+ iso_datetime=iso_dt,
242
+ subject=subject_header,
243
+ body=extract_email_body_in_plain_text(mdata),
244
+ )
245
+ )
246
+
247
+ if len(items) >= max_fetch:
248
+ break
249
+
250
+ next_page_token = list_data.get("nextPageToken")
251
+ if not next_page_token:
252
+ break
253
+ except httpx.HTTPStatusError as exc:
254
+ _rethrow_with_google_message(exc, "Gmail List OAuth")
255
+
256
+ return items
257
+
258
+
259
+ async def reply_to_email_google_oauth_async(
260
+ reply_email_context: ReplyEmailContext,
261
+ tool_config: Optional[List[Dict]] = None,
262
+ ) -> Dict[str, Any]:
263
+ """
264
+ Reply-all to a Gmail message for the connected user using OAuth2.
265
+ Returns a metadata dictionary similar to other providers.
266
+ """
267
+ if reply_email_context.add_labels is None:
268
+ reply_email_context.add_labels = []
269
+
270
+ token = get_google_access_token(tool_config)
271
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
272
+ base = "https://gmail.googleapis.com/gmail/v1/users/me"
273
+
274
+ # 1) Fetch original message
275
+ get_url = f"{base}/messages/{reply_email_context.message_id}"
276
+ params = {"format": "full"}
277
+ async with httpx.AsyncClient(timeout=30) as client:
278
+ try:
279
+ get_resp = await client.get(get_url, headers=headers, params=params)
280
+ get_resp.raise_for_status()
281
+ original = get_resp.json() or {}
282
+ except httpx.HTTPStatusError as exc:
283
+ _rethrow_with_google_message(exc, "Gmail Fetch Message OAuth")
284
+
285
+ headers_list = (original.get("payload") or {}).get("headers", [])
286
+ # Use case-insensitive lookups via find_header to avoid missing values on header casing differences.
287
+ subject = find_header(headers_list, "Subject") or ""
288
+ if not subject.startswith("Re:"):
289
+ subject = f"Re: {subject}"
290
+ reply_to_header = find_header(headers_list, "Reply-To") or ""
291
+ from_header = find_header(headers_list, "From") or ""
292
+ to_header = find_header(headers_list, "To") or ""
293
+ cc_header = find_header(headers_list, "Cc") or ""
294
+ message_id_header = find_header(headers_list, "Message-ID") or ""
295
+ thread_id = original.get("threadId")
296
+
297
+ sender_email_lc = (reply_email_context.sender_email or "").lower()
298
+
299
+ def _is_self(addr: str) -> bool:
300
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
301
+
302
+ cc_addresses = cc_header or ""
303
+ # Prefer Reply-To unless it points back to the sender. If the original was SENT mail,
304
+ # From will equal the sender, so we should reply to the original To/CC instead.
305
+ if reply_to_header and not _is_self(reply_to_header):
306
+ to_addresses = reply_to_header
307
+ elif from_header and not _is_self(from_header):
308
+ to_addresses = from_header
309
+ elif to_header and not _is_self(to_header):
310
+ to_addresses = to_header
311
+ else:
312
+ combined = ", ".join([v for v in (to_header, cc_header, from_header) if v])
313
+ to_addresses = combined
314
+ cc_addresses = ""
315
+
316
+ if (not to_addresses or _is_self(to_addresses)) and reply_email_context.fallback_recipient:
317
+ if not _is_self(reply_email_context.fallback_recipient):
318
+ to_addresses = reply_email_context.fallback_recipient
319
+ cc_addresses = ""
320
+
321
+ if not to_addresses or _is_self(to_addresses):
322
+ raise ValueError(
323
+ "No valid recipient found in the original message; refusing to reply to sender."
324
+ )
325
+
326
+ # 2) Build reply MIME
327
+ plain_reply, html_reply, resolved_reply_fmt = body_variants(
328
+ reply_email_context.reply_body,
329
+ getattr(reply_email_context, "reply_body_format", None),
330
+ )
331
+ if resolved_reply_fmt == "text":
332
+ msg = MIMEText(plain_reply, "plain", _charset="utf-8")
333
+ else:
334
+ msg = MIMEMultipart("alternative")
335
+ msg.attach(MIMEText(plain_reply, "plain", _charset="utf-8"))
336
+ msg.attach(MIMEText(html_reply, "html", _charset="utf-8"))
337
+
338
+ msg["To"] = to_addresses
339
+ if cc_addresses:
340
+ msg["Cc"] = cc_addresses
341
+ msg["From"] = f"{reply_email_context.sender_name} <{reply_email_context.sender_email}>"
342
+ msg["Subject"] = subject
343
+ if message_id_header:
344
+ msg["In-Reply-To"] = message_id_header
345
+ msg["References"] = message_id_header
346
+
347
+ raw_message = base64.urlsafe_b64encode(msg.as_bytes()).decode()
348
+ payload = {"raw": raw_message}
349
+ if thread_id:
350
+ payload["threadId"] = thread_id
351
+
352
+ # 3) Send the reply
353
+ send_url = f"{base}/messages/send"
354
+ async with httpx.AsyncClient(timeout=30) as client:
355
+ try:
356
+ send_resp = await client.post(send_url, headers=headers, json=payload)
357
+ send_resp.raise_for_status()
358
+ sent = send_resp.json() or {}
359
+ except httpx.HTTPStatusError as exc:
360
+ _rethrow_with_google_message(exc, "Gmail Send Reply OAuth")
361
+
362
+ # 4) Optional: mark as read
363
+ if str(reply_email_context.mark_as_read).lower() == "true" and thread_id:
364
+ modify_url = f"{base}/threads/{thread_id}/modify"
365
+ modify_payload = {"removeLabelIds": ["UNREAD"]}
366
+ try:
367
+ async with httpx.AsyncClient(timeout=30) as client:
368
+ await client.post(modify_url, headers=headers, json=modify_payload)
369
+ except Exception:
370
+ logging.exception("Gmail: failed to mark thread as read (best-effort)")
371
+
372
+ # 5) Optional: add labels
373
+ if reply_email_context.add_labels and thread_id:
374
+ modify_url = f"{base}/threads/{thread_id}/modify"
375
+ modify_payload = {"addLabelIds": reply_email_context.add_labels}
376
+ try:
377
+ async with httpx.AsyncClient(timeout=30) as client:
378
+ await client.post(modify_url, headers=headers, json=modify_payload)
379
+ except Exception:
380
+ logging.exception("Gmail: failed to add labels to thread (best-effort)")
381
+
382
+ return {
383
+ "mailbox_email_id": sent.get("id"),
384
+ "message_id": (sent.get("threadId") or thread_id or ""),
385
+ "email_subject": subject,
386
+ "email_sender": reply_email_context.sender_email,
387
+ "email_recipients": [to_addresses] + ([cc_addresses] if cc_addresses else []),
388
+ "read_email_status": "READ" if str(reply_email_context.mark_as_read).lower() == "true" else "UNREAD",
389
+ "email_labels": sent.get("labelIds", []),
390
+ }
391
+
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # Google Calendar (OAuth per-user)
395
+ # ---------------------------------------------------------------------------
396
+
397
+ @assistant_tool
398
+ async def get_calendar_events_using_google_oauth_async(
399
+ start_date: str,
400
+ end_date: str,
401
+ tool_config: Optional[List[Dict]] = None,
402
+ ) -> List[Dict[str, Any]]:
403
+ """
404
+ Retrieve events from the user's primary Google Calendar using a per-user OAuth token.
405
+
406
+ start_date, end_date: 'YYYY-MM-DD' strings (inclusive start, inclusive end day as 23:59:59Z).
407
+ Returns a list of event dicts from the Calendar API.
408
+ """
409
+ token = get_google_access_token(tool_config)
410
+ headers = {"Authorization": f"Bearer {token}"}
411
+ url = "https://www.googleapis.com/calendar/v3/calendars/primary/events"
412
+
413
+ time_min = f"{start_date}T00:00:00Z"
414
+ time_max = f"{end_date}T23:59:59Z"
415
+ params = {
416
+ "timeMin": time_min,
417
+ "timeMax": time_max,
418
+ "maxResults": 10,
419
+ "singleEvents": True,
420
+ "orderBy": "startTime",
421
+ }
422
+
423
+ async with httpx.AsyncClient(timeout=30) as client:
424
+ try:
425
+ resp = await client.get(url, headers=headers, params=params)
426
+ resp.raise_for_status()
427
+ data = resp.json() or {}
428
+ events = data.get("items", [])
429
+ if not events:
430
+ logging.info("No upcoming events found within the specified range (OAuth).")
431
+ return events
432
+ except httpx.HTTPStatusError as exc:
433
+ _rethrow_with_google_message(exc, "Calendar OAuth")
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # Google Sheets and Docs (OAuth per-user)
438
+ # ---------------------------------------------------------------------------
439
+
440
+ def _get_sheet_id_from_url(sheet_url: str) -> str:
441
+ match = re.search(r"/d/([a-zA-Z0-9-_]+)/", sheet_url)
442
+ if not match:
443
+ raise ValueError("Could not extract spreadsheet ID from the provided URL.")
444
+ return match.group(1)
445
+
446
+
447
+ def _get_document_id_from_url(doc_url: str) -> str:
448
+ match = re.search(r"/d/([a-zA-Z0-9-_]+)/", doc_url)
449
+ if not match:
450
+ raise ValueError("Could not extract document ID from the provided URL.")
451
+ return match.group(1)
452
+
453
+
454
+ @assistant_tool
455
+ async def read_google_sheet_using_google_oauth(
456
+ sheet_url: str,
457
+ range_name: str,
458
+ tool_config: Optional[List[Dict]] = None,
459
+ ) -> List[List[str]]:
460
+ """
461
+ Read data from a Google Sheet using the connected user's OAuth token.
462
+
463
+ If range_name is empty, reads the first sheet tab by fetching spreadsheet metadata.
464
+ """
465
+ token = get_google_access_token(tool_config)
466
+ headers = {"Authorization": f"Bearer {token}"}
467
+
468
+ # If the GCP project requires a quota/billing project with OAuth, allow an optional header
469
+ def _quota_project(cfg: _Optional[List[Dict]]) -> _Optional[str]:
470
+ try:
471
+ g_cfg = next((c for c in (cfg or []) if c.get("name") == "google"), None)
472
+ if not g_cfg:
473
+ return None
474
+ cmap = {f["name"]: f.get("value") for f in g_cfg.get("configuration", []) if f}
475
+ return (
476
+ cmap.get("quota_project")
477
+ or cmap.get("quotaProjectId")
478
+ or cmap.get("project_id")
479
+ or cmap.get("x_goog_user_project")
480
+ or cmap.get("google_cloud_project")
481
+ )
482
+ except Exception:
483
+ return None
484
+
485
+ qp = _quota_project(tool_config)
486
+ if qp:
487
+ headers["X-Goog-User-Project"] = qp
488
+
489
+ spreadsheet_id = _get_sheet_id_from_url(sheet_url)
490
+
491
+ async def _oauth_fetch() -> List[List[str]]:
492
+ nonlocal range_name
493
+ # Default range to first sheet title if not supplied
494
+ if not range_name:
495
+ meta_url = f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}"
496
+ params = {"fields": "sheets(properties(title))"}
497
+ async with httpx.AsyncClient(timeout=30) as client:
498
+ meta_resp = await client.get(meta_url, headers=headers, params=params)
499
+ meta_resp.raise_for_status()
500
+ meta = meta_resp.json() or {}
501
+ sheets = meta.get("sheets", [])
502
+ if not sheets:
503
+ return []
504
+ range_name = (sheets[0].get("properties") or {}).get("title") or "Sheet1"
505
+
506
+ values_url = f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}/values/{range_name}"
507
+ async with httpx.AsyncClient(timeout=30) as client:
508
+ val_resp = await client.get(values_url, headers=headers)
509
+ val_resp.raise_for_status()
510
+ data = val_resp.json() or {}
511
+ return data.get("values", [])
512
+
513
+ try:
514
+ return await _oauth_fetch()
515
+ except httpx.HTTPStatusError as exc:
516
+ # If OAuth fails with 403 (likely insufficient scope or access), fail with clear guidance
517
+ status = getattr(getattr(exc, "response", None), "status_code", None)
518
+ if status == 403:
519
+ api_msg = _extract_google_api_message(exc.response) or "Access forbidden by Google API (403)."
520
+ guidance = (
521
+ "Google Sheets access denied with OAuth. Ensure the connected Google account can access the spreadsheet "
522
+ "(share with the account if private) and that the OAuth token includes the Sheets scope "
523
+ "('https://www.googleapis.com/auth/spreadsheets.readonly' or 'https://www.googleapis.com/auth/spreadsheets')."
524
+ )
525
+ raise httpx.HTTPStatusError(
526
+ f"403 Forbidden (Sheets OAuth). {api_msg} {guidance}", request=exc.request, response=exc.response
527
+ )
528
+ # For other statuses, rethrow with Google's message
529
+ _rethrow_with_google_message(exc, "Sheets OAuth")
530
+
531
+
532
+ @assistant_tool
533
+ async def read_google_document_using_google_oauth(
534
+ doc_url: str,
535
+ tool_config: Optional[List[Dict]] = None,
536
+ ) -> str:
537
+ """
538
+ Read text content from a Google Doc using the connected user's OAuth token.
539
+ Concatenates all text runs in the document body.
540
+ """
541
+ token = get_google_access_token(tool_config)
542
+ headers = {"Authorization": f"Bearer {token}"}
543
+
544
+ document_id = _get_document_id_from_url(doc_url)
545
+ url = f"https://docs.googleapis.com/v1/documents/{document_id}"
546
+
547
+ async with httpx.AsyncClient(timeout=30) as client:
548
+ try:
549
+ resp = await client.get(url, headers=headers)
550
+ resp.raise_for_status()
551
+ doc = resp.json() or {}
552
+ except httpx.HTTPStatusError as exc:
553
+ _rethrow_with_google_message(exc, "Docs OAuth")
554
+
555
+ content = (doc.get("body") or {}).get("content", [])
556
+ parts: List[str] = []
557
+ for element in content:
558
+ paragraph = element.get("paragraph")
559
+ if not paragraph:
560
+ continue
561
+ for elem in paragraph.get("elements", []) or []:
562
+ text_run = elem.get("textRun")
563
+ if text_run:
564
+ parts.append(text_run.get("content", ""))
565
+
566
+ return "".join(parts)
567
+
568
+
569
+ @assistant_tool
570
+ async def search_google_custom_search(
571
+ query: str,
572
+ number_of_results: int = 10,
573
+ offset: int = 0,
574
+ tool_config: _Optional[List[Dict]] = None,
575
+ as_oq: _Optional[str] = None,
576
+ ) -> List[str]:
577
+ """
578
+ Search Google using the Custom Search JSON API with a per-user OAuth token.
579
+
580
+ Requires a Programmable Search Engine ID (cx) from the 'google_custom_search' integration
581
+ or env var 'GOOGLE_SEARCH_CX'. Returns a list of JSON strings with
582
+ { position, title, link, snippet } items.
583
+ """
584
+ # Final query composition
585
+ full_query = query if not as_oq else f"{query} {as_oq}"
586
+
587
+ # Acquire OAuth token and CX id
588
+ token = get_google_access_token(tool_config)
589
+
590
+ cx: Optional[str] = None
591
+ if tool_config:
592
+ gcs_cfg = next((c for c in tool_config if c.get("name") == "google_custom_search"), None)
593
+ if gcs_cfg:
594
+ cfg_map = {f["name"]: f.get("value") for f in gcs_cfg.get("configuration", []) if f}
595
+ cx = cfg_map.get("cx")
596
+ if not cx:
597
+ import os as _os
598
+ cx = _os.environ.get("GOOGLE_SEARCH_CX")
599
+ if not cx:
600
+ err = (
601
+ "Google Custom Search CX is not configured. Please add 'google_custom_search' integration with 'cx',"
602
+ " or set GOOGLE_SEARCH_CX."
603
+ )
604
+ logging.error(err)
605
+ return [json.dumps({"error": err})]
606
+
607
+ # Pagination: start=1-based index
608
+ start_index = max(1, int(offset) + 1)
609
+
610
+ url = "https://www.googleapis.com/customsearch/v1"
611
+ params = {
612
+ "q": full_query,
613
+ "num": number_of_results,
614
+ "start": start_index,
615
+ "cx": cx,
616
+ }
617
+ headers = {"Authorization": f"Bearer {token}"}
618
+
619
+ cache_key = f"oauth_cse:{full_query}:{number_of_results}:{offset}:{cx}"
620
+ cached = retrieve_output("search_google_custom_search_oauth", cache_key)
621
+ if cached is not None:
622
+ return cached
623
+
624
+ async with httpx.AsyncClient(timeout=30) as client:
625
+ try:
626
+ resp = await client.get(url, headers=headers, params=params)
627
+ if resp.status_code == 429:
628
+ return [json.dumps({"error": "Rate limit exceeded (429)"})]
629
+ resp.raise_for_status()
630
+ data = resp.json() or {}
631
+
632
+ items = data.get("items", []) or []
633
+ norm: List[Dict[str, Any]] = []
634
+ for i, item in enumerate(items):
635
+ norm.append({
636
+ "position": i + 1,
637
+ "title": item.get("title", ""),
638
+ "link": item.get("link", ""),
639
+ "snippet": item.get("snippet", ""),
640
+ })
641
+ out = [json.dumps(o) for o in norm]
642
+ cache_output("search_google_custom_search_oauth", cache_key, out)
643
+ return out
644
+ except httpx.HTTPStatusError as exc:
645
+ try:
646
+ err_json = exc.response.json()
647
+ except Exception:
648
+ err_json = {"status": exc.response.status_code, "text": exc.response.text}
649
+ logging.warning(f"CSE OAuth request failed: {err_json}")
650
+ return [json.dumps({"error": err_json})]
651
+ except Exception as e:
652
+ logging.exception("CSE OAuth request failed")
653
+ return [json.dumps({"error": str(e)})]
654
+
655
+ @assistant_tool
656
+ async def search_google_places(
657
+ query: str,
658
+ location_bias: dict = None,
659
+ number_of_results: int = 3,
660
+ tool_config: _Optional[List[Dict]] = None,
661
+ ) -> List[str]:
662
+ """
663
+ Search Google Places (New) with a per-user OAuth token.
664
+
665
+ - Requires that the OAuth token has Maps/Places access enabled for the project.
666
+ - Returns a list of JSON strings, each being a place object.
667
+ """
668
+
669
+
670
+ token = get_google_access_token(tool_config)
671
+ url = "https://places.googleapis.com/v1/places:searchText"
672
+ headers = {
673
+ "Content-Type": "application/json",
674
+ "Authorization": f"Bearer {token}",
675
+ # Field mask is required to limit returned fields
676
+ "X-Goog-FieldMask": (
677
+ "places.displayName,places.formattedAddress,places.location,"
678
+ "places.websiteUri,places.rating,places.reviews"
679
+ ),
680
+ }
681
+
682
+ body: Dict[str, Any] = {"textQuery": query}
683
+ if location_bias:
684
+ body["locationBias"] = {
685
+ "circle": {
686
+ "center": {
687
+ "latitude": location_bias.get("latitude"),
688
+ "longitude": location_bias.get("longitude"),
689
+ },
690
+ "radius": location_bias.get("radius", 5000),
691
+ }
692
+ }
693
+
694
+ # Cache key based on query, count and bias
695
+ bias_str = json.dumps(location_bias, sort_keys=True) if location_bias else "None"
696
+ cache_key = f"oauth_places:{query}:{number_of_results}:{bias_str}"
697
+ cached = retrieve_output("search_google_places_oauth", cache_key)
698
+ if cached is not None:
699
+ return cached
700
+
701
+ async with httpx.AsyncClient(timeout=30) as client:
702
+ try:
703
+ resp = await client.post(url, headers=headers, json=body)
704
+ if resp.status_code == 429:
705
+ return [json.dumps({"error": "Rate limit exceeded (429)"})]
706
+ resp.raise_for_status()
707
+ data = resp.json() or {}
708
+ places = (data.get("places") or [])[: max(0, int(number_of_results))]
709
+ out = [json.dumps(p) for p in places]
710
+ cache_output("search_google_places_oauth", cache_key, out)
711
+ return out
712
+ except httpx.HTTPStatusError as exc:
713
+ try:
714
+ err_json = exc.response.json()
715
+ except Exception:
716
+ err_json = {"status": exc.response.status_code, "text": exc.response.text}
717
+ logging.warning(f"Places OAuth request failed: {err_json}")
718
+ return [json.dumps({"error": err_json})]
719
+ except Exception as e:
720
+ logging.exception("Places OAuth request failed")
721
+ return [json.dumps({"error": str(e)})]