dhisana 0.0.1.dev243__py3-none-any.whl

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