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,1294 @@
1
+ import base64
2
+ import csv
3
+ import datetime
4
+ import html as html_lib
5
+ import io
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import uuid
11
+ from email.mime.multipart import MIMEMultipart
12
+ from email.mime.text import MIMEText
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ import httpx
16
+ from pydantic import BaseModel
17
+
18
+ from google.auth.transport.requests import Request
19
+ from google.oauth2 import service_account
20
+ from googleapiclient.discovery import build
21
+ from googleapiclient.errors import HttpError
22
+ from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
23
+
24
+ from dhisana.schemas.sales import MessageItem
25
+ from dhisana.utils.assistant_tool_tag import assistant_tool
26
+ from dhisana.utils.email_parse_helpers import *
27
+ from dhisana.utils.email_body_utils import body_variants
28
+ import asyncio
29
+ from dhisana.schemas.common import (SendEmailContext, QueryEmailContext, ReplyEmailContext, BodyFormat)
30
+
31
+
32
+ ################################################################################
33
+ # HELPER FUNCTIONS
34
+ ################################################################################
35
+
36
+ def get_google_workspace_token(tool_config: Optional[List[Dict]] = None) -> str:
37
+ """
38
+ Retrieves the GOOGLE_SERVICE_KEY (base64-encoded JSON) from the provided tool configuration or environment.
39
+
40
+ Args:
41
+ tool_config (list): A list of dictionaries containing the tool configuration.
42
+ Each dictionary should have a "name" key and a "configuration" key,
43
+ where "configuration" is a list of dictionaries containing "name" and "value" keys.
44
+
45
+ Returns:
46
+ str: The base64-encoded JSON string for the service account credentials.
47
+
48
+ Raises:
49
+ ValueError: If the Google Workspace integration has not been configured.
50
+ """
51
+ if tool_config:
52
+ google_workspace_config = next(
53
+ (item for item in tool_config if item.get("name") == "googleworkspace"), None
54
+ )
55
+ if google_workspace_config:
56
+ config_map = {
57
+ item["name"]: item["value"]
58
+ for item in google_workspace_config.get("configuration", [])
59
+ if item
60
+ }
61
+ GOOGLE_SERVICE_KEY = config_map.get("apiKey")
62
+ else:
63
+ GOOGLE_SERVICE_KEY = None
64
+ else:
65
+ GOOGLE_SERVICE_KEY = None
66
+
67
+ if not GOOGLE_SERVICE_KEY:
68
+ env_service_key = os.getenv("GOOGLE_SERVICE_KEY")
69
+ if env_service_key:
70
+ GOOGLE_SERVICE_KEY = base64.b64decode(env_service_key).decode("utf-8")
71
+ if not GOOGLE_SERVICE_KEY:
72
+ raise ValueError(
73
+ "Google Workspace integration is not configured. Please configure the connection to Google Workspace in Integrations."
74
+ )
75
+ return GOOGLE_SERVICE_KEY
76
+
77
+
78
+
79
+ def get_google_credentials(
80
+ sender_email: str,
81
+ scopes: List[str],
82
+ tool_config: Optional[List[Dict]] = None
83
+ ):
84
+ """
85
+ Retrieves OAuth2 credentials for a given sender_email (impersonation) and set of scopes.
86
+
87
+ Args:
88
+ sender_email (str): The email address to impersonate using domain-wide delegation.
89
+ Must be authorized in the service account domain.
90
+ scopes (List[str]): The list of OAuth scopes required.
91
+ tool_config (Optional[List[Dict]]): Tool configuration, if any (used to fetch service key).
92
+
93
+ Returns:
94
+ google.oauth2.service_account.Credentials: The credentials object.
95
+ """
96
+ if not sender_email:
97
+ raise ValueError("sender_email is required to impersonate via service account.")
98
+
99
+ service_account_json = get_google_workspace_token(tool_config)
100
+ service_account_info = json.loads(service_account_json)
101
+
102
+ # Create Credentials object and impersonate the sender_email
103
+ credentials = service_account.Credentials.from_service_account_info(
104
+ service_account_info, scopes=scopes
105
+ ).with_subject(sender_email)
106
+
107
+ # Refresh if needed
108
+ if not credentials.valid:
109
+ request = Request()
110
+ credentials.refresh(request)
111
+
112
+ return credentials
113
+
114
+
115
+ def _looks_like_html(text: str) -> bool:
116
+ """Heuristically determine whether the body contains HTML markup."""
117
+ return bool(text and re.search(r"<[a-zA-Z][^>]*>", text))
118
+
119
+
120
+ def _html_to_plain_text(html: str) -> str:
121
+ """
122
+ Produce a very lightweight plain-text version of an HTML fragment.
123
+ This keeps newlines on block boundaries and strips tags.
124
+ """
125
+ if not html:
126
+ return ""
127
+ text = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", html)
128
+ text = re.sub(r"(?i)<br\s*/?>", "\n", text)
129
+ text = re.sub(r"(?i)</(p|div|li|h[1-6])\s*>", "\n", text)
130
+ text = re.sub(r"(?is)<.*?>", "", text)
131
+ text = html_lib.unescape(text)
132
+ text = re.sub(r"\s+\n", "\n", text)
133
+ text = re.sub(r"\n{3,}", "\n\n", text)
134
+ return text.strip()
135
+
136
+
137
+
138
+ @assistant_tool
139
+ async def send_email_using_service_account_async(
140
+ send_email_context: SendEmailContext,
141
+ tool_config: Optional[List[Dict]] = None
142
+ ) -> str:
143
+ """
144
+ Asynchronously sends an email using the Gmail API with a service account.
145
+ The service account must have domain-wide delegation to impersonate the sender_email.
146
+
147
+ Args:
148
+ send_email_context (SendEmailContext): The context with recipient, subject,
149
+ body, sender_name, sender_email,
150
+ and an optional labels list.
151
+ tool_config (Optional[List[Dict]]): Tool configuration for credentials (if any).
152
+
153
+ Returns:
154
+ str: The ID of the sent message.
155
+ """
156
+ if not send_email_context.sender_email:
157
+ raise ValueError("sender_email is required to impersonate for sending.")
158
+
159
+ SCOPES = ['https://mail.google.com/']
160
+ credentials = get_google_credentials(send_email_context.sender_email, SCOPES, tool_config)
161
+ access_token = credentials.token
162
+
163
+ gmail_api_url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'
164
+
165
+ plain_body, html_body, resolved_fmt = body_variants(
166
+ send_email_context.body,
167
+ getattr(send_email_context, "body_format", None),
168
+ )
169
+
170
+ if resolved_fmt == "text":
171
+ message = MIMEText(plain_body, _subtype="plain", _charset="utf-8")
172
+ else:
173
+ # Gmail prefers multipart/alternative when HTML is present.
174
+ message = MIMEMultipart("alternative")
175
+ message.attach(MIMEText(plain_body, "plain", _charset="utf-8"))
176
+ message.attach(MIMEText(html_body, "html", _charset="utf-8"))
177
+
178
+ message['to'] = send_email_context.recipient
179
+ message['from'] = f"{send_email_context.sender_name} <{send_email_context.sender_email}>"
180
+ message['subject'] = send_email_context.subject
181
+
182
+ extra_headers = getattr(send_email_context, "headers", None) or {}
183
+ for header, value in extra_headers.items():
184
+ if not header or value is None:
185
+ continue
186
+ message[header] = str(value)
187
+
188
+ # Base64-encode the message
189
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
190
+
191
+ # Build the payload (with optional label IDs)
192
+ payload = {
193
+ 'raw': raw_message
194
+ }
195
+ if send_email_context.labels:
196
+ payload['labelIds'] = send_email_context.labels
197
+
198
+ headers = {
199
+ 'Authorization': f'Bearer {access_token}',
200
+ 'Content-Type': 'application/json'
201
+ }
202
+
203
+ async with httpx.AsyncClient() as client:
204
+ response = await client.post(gmail_api_url, headers=headers, json=payload)
205
+ response.raise_for_status()
206
+ sent_message = response.json()
207
+ await asyncio.sleep(20)
208
+
209
+ return sent_message.get('id', 'No ID returned')
210
+
211
+
212
+
213
+
214
+ @assistant_tool
215
+ async def list_emails_in_time_range_async(
216
+ context: QueryEmailContext,
217
+ tool_config: Optional[List[Dict]] = None
218
+ ) -> List[MessageItem]:
219
+ """
220
+ Asynchronously lists emails in a given time range using the Gmail API with a service account.
221
+ Returns a list of MessageItem objects, with iso_datetime, and separate sender/receiver fields.
222
+ """
223
+ if context.labels is None:
224
+ context.labels = []
225
+
226
+ if not context.sender_email:
227
+ raise ValueError("sender_email is required to impersonate for listing emails.")
228
+
229
+ SCOPES = ['https://mail.google.com/']
230
+ credentials = get_google_credentials(context.sender_email, SCOPES, tool_config)
231
+ access_token = credentials.token
232
+
233
+ gmail_api_url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages'
234
+
235
+ # Convert RFC 3339 times to Unix epoch timestamps for the search query
236
+ start_dt = datetime.datetime.fromisoformat(context.start_time.replace('Z', '+00:00'))
237
+ end_dt = datetime.datetime.fromisoformat(context.end_time.replace('Z', '+00:00'))
238
+ start_timestamp = int(start_dt.timestamp())
239
+ end_timestamp = int(end_dt.timestamp())
240
+
241
+ # Build the search query
242
+ query = f'after:{start_timestamp} before:{end_timestamp}'
243
+ if context.unread_only:
244
+ query += ' is:unread'
245
+ if context.labels:
246
+ label_query = ' '.join([f'label:{lbl}' for lbl in context.labels])
247
+ query += f' {label_query}'
248
+
249
+ headers = {'Authorization': f'Bearer {access_token}'}
250
+ params = {'q': query, 'maxResults': 100}
251
+
252
+ message_items: List[MessageItem] = []
253
+ max_fetch = 500 # defensive cap
254
+ async with httpx.AsyncClient() as client:
255
+ next_page_token = None
256
+ while True:
257
+ page_params = dict(params)
258
+ if next_page_token:
259
+ page_params["pageToken"] = next_page_token
260
+
261
+ response = await client.get(gmail_api_url, headers=headers, params=page_params)
262
+ response.raise_for_status()
263
+ resp_json = response.json() or {}
264
+ messages = resp_json.get('messages', [])
265
+
266
+ for msg in messages:
267
+ if len(message_items) >= max_fetch:
268
+ break
269
+ message_id = msg['id']
270
+ thread_id = msg.get('threadId', "")
271
+ message_url = f'{gmail_api_url}/{message_id}'
272
+ message_response = await client.get(message_url, headers=headers)
273
+ message_response.raise_for_status()
274
+ message_data = message_response.json()
275
+
276
+ headers_list = message_data['payload']['headers']
277
+ from_header = find_header(headers_list, 'From') or ""
278
+ subject_header = find_header(headers_list, 'Subject') or ""
279
+ date_header = find_header(headers_list, 'Date') or ""
280
+
281
+ iso_datetime_str = convert_date_to_iso(date_header)
282
+
283
+ # Parse the "From" into (sender_name, sender_email)
284
+ s_name, s_email = parse_single_address(from_header)
285
+
286
+ # Parse the recipients
287
+ r_name, r_email = find_all_recipients_in_headers(headers_list)
288
+
289
+ msg_item = MessageItem(
290
+ message_id=message_data['id'],
291
+ thread_id=thread_id,
292
+ sender_name=s_name,
293
+ sender_email=s_email,
294
+ receiver_name=r_name,
295
+ receiver_email=r_email,
296
+ iso_datetime=iso_datetime_str,
297
+ subject=subject_header,
298
+ body=extract_email_body_in_plain_text(message_data)
299
+ )
300
+ message_items.append(msg_item)
301
+
302
+ if len(message_items) >= max_fetch:
303
+ break
304
+
305
+ next_page_token = resp_json.get("nextPageToken")
306
+ if not next_page_token:
307
+ break
308
+
309
+ return message_items
310
+
311
+
312
+ ################################################################################
313
+ # GOOGLE DRIVE FILE OPERATIONS
314
+ ################################################################################
315
+
316
+ @assistant_tool
317
+ async def get_file_content_from_googledrive_by_name(
318
+ file_name: str,
319
+ sender_email: str,
320
+ tool_config: Optional[List[Dict]] = None
321
+ ) -> str:
322
+ """
323
+ Searches for a file by name in Google Drive using a service account, downloads it,
324
+ saves it in /tmp with a unique filename, and returns the local file path.
325
+
326
+ Args:
327
+ file_name (str): The name of the file to search for in Google Drive.
328
+ sender_email (str): The email address to impersonate. Must have domain-wide delegation set up.
329
+ tool_config (Optional[List[Dict]]): Tool configuration. Contains the service account base64 key if not in env.
330
+
331
+ Returns:
332
+ str: Local file path of the downloaded file.
333
+
334
+ Raises:
335
+ FileNotFoundError: If no file is found with the given file_name.
336
+ HttpError: If there's an error with the Drive API call.
337
+ """
338
+ if not file_name:
339
+ raise ValueError("file_name must be provided.")
340
+
341
+ # Set up credentials
342
+ SCOPES = ['https://www.googleapis.com/auth/drive']
343
+ credentials = get_google_credentials(sender_email, SCOPES, tool_config)
344
+
345
+ # Build the Drive service
346
+ service = build('drive', 'v3', credentials=credentials)
347
+
348
+ # Search for the file by name
349
+ query = f"name = '{file_name}'"
350
+ results = service.files().list(q=query, pageSize=1, fields="files(id, name)").execute()
351
+ items = results.get('files', [])
352
+
353
+ if not items:
354
+ raise FileNotFoundError(f"No file found with the name: {file_name}")
355
+
356
+ # Get the file ID of the first matching file
357
+ file_id = items[0]['id']
358
+ actual_file_name = items[0]['name'] # Keep original name
359
+
360
+ # Create a unique filename by appending a UUID
361
+ unique_filename = f"{uuid.uuid4()}_{actual_file_name}"
362
+ local_file_path = os.path.join('/tmp', unique_filename)
363
+
364
+ # Request the file content from Google Drive
365
+ request = service.files().get_media(fileId=file_id)
366
+
367
+ with io.FileIO(local_file_path, 'wb') as fh:
368
+ downloader = MediaIoBaseDownload(fh, request)
369
+ done = False
370
+ while not done:
371
+ status, done = downloader.next_chunk()
372
+ if status:
373
+ logging.info(f"{actual_file_name} Download {int(status.progress() * 100)}%.")
374
+
375
+ return local_file_path
376
+
377
+
378
+ @assistant_tool
379
+ async def write_content_to_googledrive(
380
+ cloud_file_path: str,
381
+ local_file_path: str,
382
+ sender_email: str,
383
+ tool_config: Optional[List[Dict]] = None
384
+ ) -> str:
385
+ """
386
+ Writes content from a local file to a file in Google Drive using a service account.
387
+ If the file does not exist in Google Drive, it creates it along with any necessary
388
+ intermediate directories.
389
+
390
+ Args:
391
+ cloud_file_path (str): The path in Drive to create or update, e.g. 'folder/subfolder/file.txt'.
392
+ local_file_path (str): The local file path whose content will be uploaded.
393
+ sender_email (str): The email address to impersonate for domain-wide delegation.
394
+ tool_config (Optional[List[Dict]]): Tool configuration for obtaining service credentials.
395
+
396
+ Returns:
397
+ str: The file ID of the uploaded or updated file.
398
+
399
+ Raises:
400
+ HttpError: If there's an error with the Drive API calls.
401
+ """
402
+ if not cloud_file_path:
403
+ raise ValueError("cloud_file_path must be provided.")
404
+ if not local_file_path:
405
+ raise ValueError("local_file_path must be provided.")
406
+
407
+ try:
408
+ SCOPES = ['https://www.googleapis.com/auth/drive']
409
+ credentials = get_google_credentials(sender_email, SCOPES, tool_config)
410
+ service = build('drive', 'v3', credentials=credentials)
411
+
412
+ # Split the cloud file path into components
413
+ path_components = cloud_file_path.strip("/").split('/')
414
+ parent_id = 'root'
415
+
416
+ # Create intermediate directories if they don't exist
417
+ for component in path_components[:-1]:
418
+ query = (
419
+ f"'{parent_id}' in parents and name = '{component}' "
420
+ f"and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
421
+ )
422
+ results = service.files().list(q=query, pageSize=1, fields="files(id, name)").execute()
423
+ items = results.get('files', [])
424
+
425
+ if items:
426
+ parent_id = items[0]['id']
427
+ else:
428
+ file_metadata = {
429
+ 'name': component,
430
+ 'mimeType': 'application/vnd.google-apps.folder',
431
+ 'parents': [parent_id]
432
+ }
433
+ folder = service.files().create(body=file_metadata, fields='id').execute()
434
+ parent_id = folder.get('id')
435
+
436
+ # Prepare the file for upload
437
+ media_body = MediaFileUpload(local_file_path, resumable=True)
438
+ file_name = path_components[-1]
439
+
440
+ # Check if the file exists in the specified directory
441
+ query = f"'{parent_id}' in parents and name = '{file_name}' and trashed = false"
442
+ results = service.files().list(q=query, pageSize=1, fields="files(id, name)").execute()
443
+ items = results.get('files', [])
444
+
445
+ if items:
446
+ file_id = items[0]['id']
447
+ service.files().update(fileId=file_id, media_body=media_body).execute()
448
+ else:
449
+ file_metadata = {
450
+ 'name': file_name,
451
+ 'parents': [parent_id]
452
+ }
453
+ created_file = service.files().create(
454
+ body=file_metadata,
455
+ media_body=media_body,
456
+ fields='id'
457
+ ).execute()
458
+ file_id = created_file.get('id')
459
+
460
+ return file_id
461
+
462
+ except HttpError as error:
463
+ raise Exception(f"write_content_to_googledrive An error occurred: {error}")
464
+
465
+
466
+ @assistant_tool
467
+ async def list_files_in_drive_folder_by_name(
468
+ folder_path: str,
469
+ sender_email: str,
470
+ tool_config: Optional[List[Dict]] = None
471
+ ) -> List[str]:
472
+ """
473
+ Lists all files in the given Google Drive folder by folder path.
474
+ If no folder path is provided, it lists files in the root folder.
475
+
476
+ Args:
477
+ folder_path (str): The path of the folder in Google Drive (e.g. '/folder/subfolder/').
478
+ sender_email (str): The email address to impersonate for domain-wide delegation.
479
+ tool_config (Optional[List[Dict]]): Tool configuration for obtaining service credentials.
480
+
481
+ Returns:
482
+ List[str]: A list of file names in the folder.
483
+
484
+ Raises:
485
+ FileNotFoundError: If the folder path is invalid or not found.
486
+ HttpError: If there's an error with the Drive API.
487
+ """
488
+ SCOPES = ['https://www.googleapis.com/auth/drive']
489
+ credentials = get_google_credentials(sender_email, SCOPES, tool_config)
490
+ service = build('drive', 'v3', credentials=credentials)
491
+
492
+ folder_id = 'root' # Start from root if folder_path is empty
493
+ folder_path = folder_path or ""
494
+
495
+ # Traverse each folder in the path
496
+ folder_names = [name for name in folder_path.strip('/').split('/') if name]
497
+ for folder_name in folder_names:
498
+ query = (
499
+ f"name = '{folder_name}' and mimeType = 'application/vnd.google-apps.folder' "
500
+ f"and '{folder_id}' in parents and trashed = false"
501
+ )
502
+ try:
503
+ results = service.files().list(
504
+ q=query, pageSize=1, fields="files(id, name)"
505
+ ).execute()
506
+ items = results.get('files', [])
507
+ if not items:
508
+ raise FileNotFoundError(
509
+ f"Folder '{folder_name}' not found under parent folder ID '{folder_id}'"
510
+ )
511
+ folder_id = items[0]['id']
512
+ except HttpError as error:
513
+ raise Exception(f"list_files_in_drive_folder_by_name An error occurred: {error}")
514
+
515
+ # Now folder_id is the ID of the desired folder
516
+ # List all files in the specified folder
517
+ try:
518
+ query = f"'{folder_id}' in parents and trashed = false"
519
+ results = service.files().list(
520
+ q=query, pageSize=1000, fields="files(id, name)"
521
+ ).execute()
522
+ items = results.get('files', [])
523
+ return [item['name'] for item in items]
524
+ except HttpError as error:
525
+ raise Exception(f"list_files_in_drive_folder_by_name An error occurred: {error}")
526
+
527
+
528
+ ################################################################################
529
+ # GMAIL EMAIL OPERATIONS
530
+ ################################################################################
531
+
532
+ class SendEmailContext(BaseModel):
533
+ recipient: str
534
+ subject: str
535
+ body: str
536
+ sender_name: str
537
+ sender_email: str
538
+ labels: Optional[List[str]]
539
+ body_format: BodyFormat = BodyFormat.AUTO
540
+ headers: Optional[Dict[str, str]] = None
541
+
542
+ @assistant_tool
543
+ async def send_email_using_service_account_async(
544
+ send_email_context: SendEmailContext,
545
+ tool_config: Optional[List[Dict]] = None
546
+ ) -> str:
547
+ """
548
+ Asynchronously sends an email using the Gmail API with a service account.
549
+ The service account must have domain-wide delegation to impersonate the sender_email.
550
+
551
+ Args:
552
+ send_email_context (SendEmailContext): The context with recipient, subject,
553
+ body, sender_name, sender_email,
554
+ and an optional labels list.
555
+ tool_config (Optional[List[Dict]]): Tool configuration for credentials (if any).
556
+
557
+ Returns:
558
+ str: The ID of the sent message.
559
+ """
560
+ if not send_email_context.sender_email:
561
+ raise ValueError("sender_email is required to impersonate for sending.")
562
+
563
+ SCOPES = ['https://mail.google.com/']
564
+ credentials = get_google_credentials(send_email_context.sender_email, SCOPES, tool_config)
565
+ access_token = credentials.token
566
+
567
+ gmail_api_url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'
568
+
569
+ plain_body, html_body, resolved_fmt = body_variants(
570
+ send_email_context.body,
571
+ getattr(send_email_context, "body_format", None),
572
+ )
573
+
574
+ # Construct the MIME text message
575
+ if resolved_fmt == "text":
576
+ message = MIMEText(plain_body, _subtype="plain", _charset="utf-8")
577
+ else:
578
+ message = MIMEMultipart("alternative")
579
+ message.attach(MIMEText(plain_body, "plain", _charset="utf-8"))
580
+ message.attach(MIMEText(html_body, "html", _charset="utf-8"))
581
+ message['to'] = send_email_context.recipient
582
+ message['from'] = f"{send_email_context.sender_name} <{send_email_context.sender_email}>"
583
+ message['subject'] = send_email_context.subject
584
+
585
+ # Base64-encode the message
586
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
587
+
588
+ # Build the payload (with optional label IDs)
589
+ payload = {
590
+ 'raw': raw_message
591
+ }
592
+ if send_email_context.labels:
593
+ payload['labelIds'] = send_email_context.labels
594
+
595
+ headers = {
596
+ 'Authorization': f'Bearer {access_token}',
597
+ 'Content-Type': 'application/json'
598
+ }
599
+
600
+ async with httpx.AsyncClient() as client:
601
+ response = await client.post(gmail_api_url, headers=headers, json=payload)
602
+ response.raise_for_status()
603
+ sent_message = response.json()
604
+ await asyncio.sleep(20)
605
+
606
+ return sent_message.get('id', 'No ID returned')
607
+
608
+
609
+
610
+ class QueryEmailContext(BaseModel):
611
+ start_time: str
612
+ end_time: str
613
+ sender_email: str
614
+ unread_only: bool = True
615
+ labels: Optional[List[str]] = None
616
+
617
+
618
+ @assistant_tool
619
+ async def list_emails_in_time_range_async(
620
+ context: QueryEmailContext,
621
+ tool_config: Optional[List[Dict]] = None
622
+ ) -> List[MessageItem]:
623
+ """
624
+ Asynchronously lists emails in a given time range using the Gmail API with a service account.
625
+ Returns a list of MessageItem objects, with iso_datetime, and separate sender/receiver fields.
626
+ """
627
+ if context.labels is None:
628
+ context.labels = []
629
+
630
+ if not context.sender_email:
631
+ raise ValueError("sender_email is required to impersonate for listing emails.")
632
+
633
+ SCOPES = ['https://mail.google.com/']
634
+ credentials = get_google_credentials(context.sender_email, SCOPES, tool_config)
635
+ access_token = credentials.token
636
+
637
+ gmail_api_url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages'
638
+
639
+ # Convert RFC 3339 times to Unix epoch timestamps for the search query
640
+ start_dt = datetime.datetime.fromisoformat(context.start_time.replace('Z', '+00:00'))
641
+ end_dt = datetime.datetime.fromisoformat(context.end_time.replace('Z', '+00:00'))
642
+ start_timestamp = int(start_dt.timestamp())
643
+ end_timestamp = int(end_dt.timestamp())
644
+
645
+ # Build the search query
646
+ query = f'after:{start_timestamp} before:{end_timestamp}'
647
+ if context.unread_only:
648
+ query += ' is:unread'
649
+ if context.labels:
650
+ label_query = ' '.join([f'label:{lbl}' for lbl in context.labels])
651
+ query += f' {label_query}'
652
+
653
+ headers = {'Authorization': f'Bearer {access_token}'}
654
+ params = {'q': query}
655
+
656
+ message_items: List[MessageItem] = []
657
+ async with httpx.AsyncClient() as client:
658
+ response = await client.get(gmail_api_url, headers=headers, params=params)
659
+ response.raise_for_status()
660
+ messages = response.json().get('messages', [])
661
+
662
+ for msg in messages:
663
+ message_id = msg['id']
664
+ thread_id = msg['threadId']
665
+ message_url = f'{gmail_api_url}/{message_id}'
666
+ message_response = await client.get(message_url, headers=headers)
667
+ message_response.raise_for_status()
668
+ message_data = message_response.json()
669
+
670
+ headers_list = message_data['payload']['headers']
671
+ from_header = find_header(headers_list, 'From') or ""
672
+ subject_header = find_header(headers_list, 'Subject') or ""
673
+ date_header = find_header(headers_list, 'Date') or ""
674
+
675
+ iso_datetime_str = convert_date_to_iso(date_header)
676
+
677
+ # Parse the "From" into (sender_name, sender_email)
678
+ s_name, s_email = parse_single_address(from_header)
679
+
680
+ # Parse the recipients
681
+ r_name, r_email = find_all_recipients_in_headers(headers_list)
682
+
683
+ msg_item = MessageItem(
684
+ message_id=message_data['id'],
685
+ thread_id=thread_id,
686
+ sender_name=s_name,
687
+ sender_email=s_email,
688
+ receiver_name=r_name,
689
+ receiver_email=r_email,
690
+ iso_datetime=iso_datetime_str,
691
+ subject=subject_header,
692
+ body=extract_email_body_in_plain_text(message_data)
693
+ )
694
+ message_items.append(msg_item)
695
+
696
+ return message_items
697
+
698
+
699
+ @assistant_tool
700
+ async def fetch_last_n_sent_messages(
701
+ recipient_email: str,
702
+ num_messages: int,
703
+ sender_email: str,
704
+ tool_config: Optional[List[Dict]] = None
705
+ ) -> List[MessageItem]:
706
+ """
707
+ Fetch the last n messages sent to a specific recipient using the Gmail API with a service account.
708
+ Returns a list of MessageItem objects with separate sender_name/sender_email, etc.
709
+ """
710
+ if not sender_email:
711
+ raise ValueError("sender_email is required to impersonate for fetching sent messages.")
712
+
713
+ SCOPES = ['https://mail.google.com/']
714
+ credentials = get_google_credentials(sender_email, SCOPES, tool_config)
715
+ access_token = credentials.token
716
+
717
+ gmail_api_url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages'
718
+ query = f'to:{recipient_email}'
719
+
720
+ headers = {'Authorization': f'Bearer {access_token}'}
721
+ params = {'q': query, 'maxResults': num_messages}
722
+
723
+ message_items: List[MessageItem] = []
724
+ async with httpx.AsyncClient() as client:
725
+ response = await client.get(gmail_api_url, headers=headers, params=params)
726
+ response.raise_for_status()
727
+ messages = response.json().get('messages', [])
728
+
729
+ for message in messages:
730
+ message_id = message['id']
731
+ message_url = f'{gmail_api_url}/{message_id}'
732
+ msg_response = await client.get(message_url, headers=headers)
733
+ msg_response.raise_for_status()
734
+ message_data = msg_response.json()
735
+
736
+ headers_list = message_data['payload']['headers']
737
+ from_header = find_header(headers_list, 'From') or ""
738
+ subject_header = find_header(headers_list, 'Subject') or ""
739
+ date_header = find_header(headers_list, 'Date') or ""
740
+ iso_datetime_str = convert_date_to_iso(date_header)
741
+
742
+ # Parse "From"
743
+ s_name, s_email = parse_single_address(from_header)
744
+ # Parse the recipients
745
+ r_name, r_email = find_all_recipients_in_headers(headers_list)
746
+
747
+ msg_item = MessageItem(
748
+ message_id=message_data['id'],
749
+ thread_id=message_data['threadId'],
750
+ sender_name=s_name,
751
+ sender_email=s_email,
752
+ receiver_name=r_name,
753
+ receiver_email=r_email,
754
+ iso_datetime=iso_datetime_str,
755
+ subject=subject_header,
756
+ body=extract_email_body_in_plain_text(message_data)
757
+ )
758
+ message_items.append(msg_item)
759
+
760
+ return message_items
761
+
762
+
763
+ @assistant_tool
764
+ async def fetch_last_n_received_messages(
765
+ sender_filter_email: str,
766
+ num_messages: int,
767
+ sender_email: str,
768
+ tool_config: Optional[List[Dict]] = None
769
+ ) -> List[MessageItem]:
770
+ """
771
+ Fetch the last n messages received from a specific sender using the Gmail API with a service account.
772
+ Returns a list of MessageItem objects.
773
+ """
774
+ if not sender_email:
775
+ raise ValueError("sender_email is required to impersonate for fetching received messages.")
776
+
777
+ SCOPES = ['https://mail.google.com/']
778
+ credentials = get_google_credentials(sender_email, SCOPES, tool_config)
779
+ access_token = credentials.token
780
+
781
+ gmail_api_url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages'
782
+ query = f'from:{sender_filter_email}'
783
+
784
+ headers = {'Authorization': f'Bearer {access_token}'}
785
+ params = {'q': query, 'maxResults': num_messages}
786
+
787
+ message_items: List[MessageItem] = []
788
+ async with httpx.AsyncClient() as client:
789
+ response = await client.get(gmail_api_url, headers=headers, params=params)
790
+ response.raise_for_status()
791
+ messages = response.json().get('messages', [])
792
+
793
+ for message in messages:
794
+ message_id = message['id']
795
+ message_url = f'{gmail_api_url}/{message_id}'
796
+ msg_response = await client.get(message_url, headers=headers)
797
+ msg_response.raise_for_status()
798
+ message_data = msg_response.json()
799
+
800
+ headers_list = message_data['payload']['headers']
801
+ from_header = find_header(headers_list, 'From') or ""
802
+ subject_header = find_header(headers_list, 'Subject') or ""
803
+ date_header = find_header(headers_list, 'Date') or ""
804
+ iso_datetime_str = convert_date_to_iso(date_header)
805
+
806
+ # Parse "From"
807
+ s_name, s_email = parse_single_address(from_header)
808
+ # Parse the recipients
809
+ r_name, r_email = find_all_recipients_in_headers(headers_list)
810
+
811
+ msg_item = MessageItem(
812
+ message_id=message_data['id'],
813
+ thread_id=message_data['threadId'],
814
+ sender_name=s_name,
815
+ sender_email=s_email,
816
+ receiver_name=r_name,
817
+ receiver_email=r_email,
818
+ iso_datetime=iso_datetime_str,
819
+ subject=subject_header,
820
+ body=extract_email_body_in_plain_text(message_data)
821
+ )
822
+ message_items.append(msg_item)
823
+
824
+ return message_items
825
+
826
+
827
+ @assistant_tool
828
+ async def get_email_details_async(
829
+ message_id: str,
830
+ sender_email: str,
831
+ tool_config: Optional[List[Dict]] = None
832
+ ) -> MessageItem:
833
+ """
834
+ Asynchronously retrieves the full details of an email using the Gmail API with a service account.
835
+ Returns a single MessageItem with separate sender_name/sender_email, etc.
836
+ """
837
+ if not sender_email:
838
+ raise ValueError("sender_email is required to impersonate for fetching email details.")
839
+
840
+ SCOPES = ['https://mail.google.com/']
841
+ credentials = get_google_credentials(sender_email, SCOPES, tool_config)
842
+ access_token = credentials.token
843
+
844
+ gmail_api_url = f'https://gmail.googleapis.com/gmail/v1/users/me/messages/{message_id}'
845
+ headers = {'Authorization': f'Bearer {access_token}'}
846
+ params = {'format': 'full'}
847
+
848
+ async with httpx.AsyncClient() as client:
849
+ response = await client.get(gmail_api_url, headers=headers, params=params)
850
+ response.raise_for_status()
851
+ message_data = response.json()
852
+
853
+ headers_list = message_data['payload']['headers']
854
+ from_header = find_header(headers_list, 'From') or ""
855
+ subject_header = find_header(headers_list, 'Subject') or ""
856
+ date_header = find_header(headers_list, 'Date') or ""
857
+ iso_datetime_str = convert_date_to_iso(date_header)
858
+
859
+ # Parse "From"
860
+ s_name, s_email = parse_single_address(from_header)
861
+ # Parse the recipients
862
+ r_name, r_email = find_all_recipients_in_headers(headers_list)
863
+
864
+ msg_item = MessageItem(
865
+ message_id=message_data['id'],
866
+ thread_id=message_data['threadId'],
867
+ sender_name=s_name,
868
+ sender_email=s_email,
869
+ receiver_name=r_name,
870
+ receiver_email=r_email,
871
+ iso_datetime=iso_datetime_str,
872
+ subject=subject_header,
873
+ body=extract_email_body_in_plain_text(message_data)
874
+ )
875
+
876
+ return msg_item
877
+
878
+
879
+
880
+ @assistant_tool
881
+ async def reply_to_email_async(
882
+ reply_email_context: ReplyEmailContext,
883
+ tool_config: Optional[List[Dict]] = None
884
+ ) -> Dict[str, Any]:
885
+ """
886
+ Asynchronously replies to an email with "Reply-All" semantics using the Gmail API and a service account.
887
+ The service account must have domain-wide delegation to impersonate the sender_email.
888
+
889
+ Args:
890
+ context (ReplyEmailContext): The context with message_id, reply_body, sender_email, sender_name,
891
+ mark_as_read, and add_labels.
892
+ tool_config (Optional[List[Dict]]): Tool configuration for credentials.
893
+
894
+ Returns:
895
+ Dict[str, Any]: A dictionary containing the details of the sent message.
896
+ """
897
+ if reply_email_context.add_labels is None:
898
+ reply_email_context.add_labels = []
899
+
900
+ if not reply_email_context.sender_email:
901
+ raise ValueError("sender_email is required to impersonate for replying to an email.")
902
+
903
+ SCOPES = ['https://mail.google.com/']
904
+ credentials = get_google_credentials(reply_email_context.sender_email, SCOPES, tool_config)
905
+ access_token = credentials.token
906
+
907
+ gmail_api_base_url = 'https://gmail.googleapis.com/gmail/v1/users/me'
908
+ get_message_url = f'{gmail_api_base_url}/messages/{reply_email_context.message_id}'
909
+ headers = {
910
+ 'Authorization': f'Bearer {access_token}',
911
+ 'Content-Type': 'application/json'
912
+ }
913
+ params = {'format': 'full'}
914
+
915
+ # 1. Retrieve original message
916
+ async with httpx.AsyncClient() as client:
917
+ response = await client.get(get_message_url, headers=headers, params=params)
918
+ response.raise_for_status()
919
+ original_message = response.json()
920
+
921
+ headers_list = original_message.get('payload', {}).get('headers', [])
922
+ # Case-insensitive header lookup and resilient recipient fallback to avoid Gmail 400s.
923
+ subject = find_header(headers_list, 'Subject') or ''
924
+ if not subject.startswith('Re:'):
925
+ subject = f'Re: {subject}'
926
+ reply_to_header = find_header(headers_list, 'Reply-To') or ''
927
+ from_header = find_header(headers_list, 'From') or ''
928
+ to_header = find_header(headers_list, 'To') or ''
929
+ cc_header = find_header(headers_list, 'Cc') or ''
930
+ message_id_header = find_header(headers_list, 'Message-ID') or ''
931
+ thread_id = original_message.get('threadId')
932
+
933
+ sender_email_lc = (reply_email_context.sender_email or '').lower()
934
+
935
+ def _is_self(addr: str) -> bool:
936
+ return bool(sender_email_lc) and sender_email_lc in addr.lower()
937
+
938
+ cc_addresses = cc_header or ''
939
+ if reply_to_header and not _is_self(reply_to_header):
940
+ to_addresses = reply_to_header
941
+ elif from_header and not _is_self(from_header):
942
+ to_addresses = from_header
943
+ elif to_header and not _is_self(to_header):
944
+ to_addresses = to_header
945
+ else:
946
+ combined = ", ".join([v for v in (to_header, cc_header, from_header) if v])
947
+ to_addresses = combined
948
+ cc_addresses = ''
949
+
950
+ if (not to_addresses or _is_self(to_addresses)) and reply_email_context.fallback_recipient:
951
+ if not _is_self(reply_email_context.fallback_recipient):
952
+ to_addresses = reply_email_context.fallback_recipient
953
+ cc_addresses = ''
954
+
955
+ if not to_addresses or _is_self(to_addresses):
956
+ raise ValueError(
957
+ "No valid recipient found in the original message; refusing to reply to sender."
958
+ )
959
+
960
+ # 3. Create the reply email message
961
+ plain_reply, html_reply, resolved_reply_fmt = body_variants(
962
+ reply_email_context.reply_body,
963
+ getattr(reply_email_context, "reply_body_format", None),
964
+ )
965
+ if resolved_reply_fmt == "text":
966
+ msg = MIMEText(plain_reply, _subtype="plain", _charset="utf-8")
967
+ else:
968
+ msg = MIMEMultipart("alternative")
969
+ msg.attach(MIMEText(plain_reply, "plain", _charset="utf-8"))
970
+ msg.attach(MIMEText(html_reply, "html", _charset="utf-8"))
971
+ msg['To'] = to_addresses
972
+ if cc_addresses:
973
+ msg['Cc'] = cc_addresses
974
+ msg['From'] = f"{reply_email_context.sender_name} <{reply_email_context.sender_email}>"
975
+ msg['Subject'] = subject
976
+ msg['In-Reply-To'] = message_id_header
977
+ msg['References'] = message_id_header
978
+
979
+ raw_message = base64.urlsafe_b64encode(msg.as_bytes()).decode()
980
+ payload = {
981
+ 'raw': raw_message,
982
+ 'threadId': thread_id
983
+ }
984
+
985
+ # 4. Send the reply
986
+ send_message_url = f'{gmail_api_base_url}/messages/send'
987
+ async with httpx.AsyncClient() as client:
988
+ response = await client.post(send_message_url, headers=headers, json=payload)
989
+ response.raise_for_status()
990
+ sent_message = response.json()
991
+
992
+ # 5. (Optional) Mark the thread as read
993
+ if reply_email_context.mark_as_read.lower() == "true":
994
+ modify_thread_url = f'{gmail_api_base_url}/threads/{thread_id}/modify'
995
+ modify_payload = {'removeLabelIds': ['UNREAD']}
996
+ async with httpx.AsyncClient() as client:
997
+ response = await client.post(modify_thread_url, headers=headers, json=modify_payload)
998
+ response.raise_for_status()
999
+
1000
+ # 6. (Optional) Add labels
1001
+ if reply_email_context.add_labels:
1002
+ modify_thread_url = f'{gmail_api_base_url}/threads/{thread_id}/modify'
1003
+ modify_payload = {'addLabelIds': reply_email_context.add_labels}
1004
+ async with httpx.AsyncClient() as client:
1005
+ response = await client.post(modify_thread_url, headers=headers, json=modify_payload)
1006
+ response.raise_for_status()
1007
+
1008
+ # Build a response object
1009
+ sent_message_details = {
1010
+ "mailbox_email_id": sent_message['id'],
1011
+ "message_id": sent_message['threadId'],
1012
+ "email_subject": subject,
1013
+ "email_sender": reply_email_context.sender_email,
1014
+ "email_recipients": [to_addresses] + ([cc_addresses] if cc_addresses else []),
1015
+ "read_email_status": 'READ' if reply_email_context.mark_as_read.lower() == "true" else 'UNREAD',
1016
+ "email_labels": sent_message.get('labelIds', [])
1017
+ }
1018
+
1019
+ return sent_message_details
1020
+
1021
+
1022
+ ################################################################################
1023
+ # GOOGLE CALENDAR EVENT OPERATIONS
1024
+ ################################################################################
1025
+
1026
+ @assistant_tool
1027
+ async def get_calendar_events_using_service_account_async(
1028
+ start_date: str,
1029
+ end_date: str,
1030
+ sender_email: str,
1031
+ tool_config: Optional[List[Dict]] = None
1032
+ ) -> List[Dict[str, Any]]:
1033
+ """
1034
+ Asynchronously retrieves a list of events from a user's Google Calendar using a service account.
1035
+ The service account must have domain-wide delegation to impersonate the user (sender_email).
1036
+ Events are filtered based on the provided start and end date range.
1037
+
1038
+ Args:
1039
+ start_date (str): The start date (inclusive) to filter events. Format: 'YYYY-MM-DD'.
1040
+ end_date (str): The end date (exclusive) to filter events. Format: 'YYYY-MM-DD'.
1041
+ sender_email (str): The mailbox email to impersonate for domain-wide delegation.
1042
+ tool_config (Optional[List[Dict]]): Tool configuration for credentials.
1043
+
1044
+ Returns:
1045
+ List[Dict[str, Any]]: A list of calendar events within the specified date range.
1046
+
1047
+ Raises:
1048
+ httpx.HTTPError, Google-related errors for any issues with the API.
1049
+ """
1050
+ if not sender_email:
1051
+ raise ValueError("sender_email is required to impersonate for calendar events.")
1052
+
1053
+ SCOPES = ['https://www.googleapis.com/auth/calendar']
1054
+ credentials = get_google_credentials(sender_email, SCOPES, tool_config)
1055
+ access_token = credentials.token
1056
+
1057
+ calendar_api_url = 'https://www.googleapis.com/calendar/v3/calendars/primary/events'
1058
+
1059
+ # Convert start and end dates to ISO 8601 format with time
1060
+ start_datetime = f'{start_date}T00:00:00Z' # UTC format
1061
+ end_datetime = f'{end_date}T23:59:59Z' # UTC format
1062
+
1063
+ params = {
1064
+ 'timeMin': start_datetime,
1065
+ 'timeMax': end_datetime,
1066
+ 'maxResults': 10,
1067
+ 'singleEvents': True,
1068
+ 'orderBy': 'startTime'
1069
+ }
1070
+ headers = {'Authorization': f'Bearer {access_token}'}
1071
+
1072
+ async with httpx.AsyncClient() as client:
1073
+ response = await client.get(calendar_api_url, params=params, headers=headers)
1074
+ response.raise_for_status()
1075
+ events_result = response.json()
1076
+
1077
+ events = events_result.get('items', [])
1078
+
1079
+ if not events:
1080
+ logging.info('No upcoming events found within the specified range.')
1081
+ else:
1082
+ logging.info('Upcoming events:')
1083
+ for event in events:
1084
+ start = event['start'].get('dateTime', event['start'].get('date'))
1085
+ logging.info(f"{start} - {event.get('summary', 'No Title')}")
1086
+
1087
+ return events
1088
+
1089
+ def get_google_sheet_token(tool_config: Optional[List[Dict]] = None) -> str:
1090
+ """
1091
+ Retrieves the Google Sheets API key from the provided tool configuration or
1092
+ the environment variable ``GOOGLE_SHEETS_API_KEY``.
1093
+
1094
+ Raises:
1095
+ ValueError: If the Google Sheets integration has not been configured.
1096
+ """
1097
+ GOOGLE_SHEETS_API_KEY = None
1098
+ if tool_config:
1099
+ google_sheet_config = next(
1100
+ (item for item in tool_config if item.get("name") == "google_sheets"), None
1101
+ )
1102
+ if google_sheet_config:
1103
+ config_map = {
1104
+ item["name"]: item["value"]
1105
+ for item in google_sheet_config.get("configuration", [])
1106
+ if item
1107
+ }
1108
+ GOOGLE_SHEETS_API_KEY = config_map.get("apiKey")
1109
+
1110
+ GOOGLE_SHEETS_API_KEY = GOOGLE_SHEETS_API_KEY or os.getenv("GOOGLE_SHEETS_API_KEY")
1111
+ if not GOOGLE_SHEETS_API_KEY:
1112
+ raise ValueError(
1113
+ "Google Sheets integration is not configured. Please configure the connection to Google Sheets in Integrations."
1114
+ )
1115
+ return GOOGLE_SHEETS_API_KEY
1116
+
1117
+ def get_sheet_id_from_url(sheet_url: str) -> str:
1118
+ """
1119
+ Extract the spreadsheet ID from a typical Google Sheets URL.
1120
+ Example URL format:
1121
+ https://docs.google.com/spreadsheets/d/<SPREADSHEET_ID>/edit#gid=0
1122
+ """
1123
+ # Regex to capture spreadsheet ID between '/d/' and the next '/'
1124
+ match = re.search(r"/d/([a-zA-Z0-9-_]+)/", sheet_url)
1125
+ if not match:
1126
+ raise ValueError("Could not extract spreadsheet ID from the provided URL.")
1127
+ return match.group(1)
1128
+
1129
+
1130
+ def get_document_id_from_url(doc_url: str) -> str:
1131
+ """Extract the document ID from a typical Google Docs URL.
1132
+
1133
+ Example URL format:
1134
+ https://docs.google.com/document/d/<DOCUMENT_ID>/edit
1135
+ """
1136
+ match = re.search(r"/d/([a-zA-Z0-9-_]+)/", doc_url)
1137
+ if not match:
1138
+ raise ValueError("Could not extract document ID from the provided URL.")
1139
+ return match.group(1)
1140
+
1141
+ async def read_google_sheet_with_api_token(
1142
+ sheet_url: str,
1143
+ range_name: str,
1144
+ sender_email: str, # kept for signature compatibility – not used
1145
+ tool_config: Optional[List[Dict]] = None
1146
+ ) -> List[List[str]]:
1147
+ """
1148
+ Read data from a *public* Google Sheet (shared “Anyone with the link → Viewer”)
1149
+ using an API key instead of OAuth credentials.
1150
+ """
1151
+
1152
+ # 1️⃣ Spreadsheet ID from the URL
1153
+ spreadsheet_id = get_sheet_id_from_url(sheet_url)
1154
+
1155
+ # 2️⃣ Grab the API key (tool_config ➜ googlesheet › apiKey, or env var)
1156
+ api_key = get_google_sheet_token(tool_config)
1157
+
1158
+ # 3️⃣ Build the Sheets service with the key
1159
+ service = build("sheets", "v4", developerKey=api_key)
1160
+ sheet = service.spreadsheets()
1161
+
1162
+ # 4️⃣ Default range to the first sheet if none supplied
1163
+ if not range_name:
1164
+ metadata = sheet.get(spreadsheetId=spreadsheet_id).execute()
1165
+ range_name = metadata["sheets"][0]["properties"]["title"]
1166
+
1167
+ # 5️⃣ Fetch the values
1168
+ result = sheet.values().get(
1169
+ spreadsheetId=spreadsheet_id,
1170
+ range=range_name
1171
+ ).execute()
1172
+
1173
+ return result.get("values", [])
1174
+
1175
+
1176
+ async def read_google_sheet(
1177
+ sheet_url: str,
1178
+ range_name: str,
1179
+ sender_email: str,
1180
+ tool_config: Optional[List[Dict]] = None
1181
+ ) -> List[List[str]]:
1182
+ """
1183
+ Read data from a Google Sheet using a service account.
1184
+
1185
+ Args:
1186
+ sheet_url (str): Full URL of the Google Sheet.
1187
+ range_name (str): Range to read from, e.g. 'Sheet1!A1:Z'.
1188
+ sender_email (str): The email address to impersonate. Must have domain-wide delegation set up.
1189
+ tool_config (Optional[List[Dict]]): Tool configuration for credentials.
1190
+
1191
+ Returns:
1192
+ List[List[str]]: A list of rows, each row is a list of cell values.
1193
+
1194
+ Raises:
1195
+ HttpError: If there's an error calling the Sheets API.
1196
+ """
1197
+
1198
+ # --- 1. Extract Spreadsheet ID from URL ---
1199
+ spreadsheet_id = get_sheet_id_from_url(sheet_url)
1200
+
1201
+ # --- 2. Set up credentials ---
1202
+ SCOPES = ['https://www.googleapis.com/auth/spreadsheets']
1203
+ credentials = get_google_credentials(sender_email, SCOPES, tool_config)
1204
+
1205
+ # --- 3. Build the Sheets service ---
1206
+ try:
1207
+ service = build('sheets', 'v4', credentials=credentials)
1208
+ sheet = service.spreadsheets()
1209
+
1210
+ # If no range_name provided, default to the first sheet
1211
+ if not range_name:
1212
+ metadata = sheet.get(spreadsheetId=spreadsheet_id).execute()
1213
+ range_name = metadata['sheets'][0]['properties']['title']
1214
+
1215
+ # --- 4. Call the Sheets API ---
1216
+ result = sheet.values().get(spreadsheetId=spreadsheet_id, range=range_name).execute()
1217
+ values = result.get('values', [])
1218
+
1219
+ return values
1220
+
1221
+ except HttpError as e:
1222
+ logging.error(f"An error occurred while reading the Google Sheet: {e}")
1223
+ raise
1224
+
1225
+
1226
+ async def read_google_document(
1227
+ doc_url: str,
1228
+ sender_email: str,
1229
+ tool_config: Optional[List[Dict]] = None,
1230
+ ) -> str:
1231
+ """Read text content from a Google Doc using a service account.
1232
+
1233
+ Args:
1234
+ doc_url (str): Full URL of the Google Document.
1235
+ sender_email (str): The email address to impersonate.
1236
+ tool_config (Optional[List[Dict]]): Tool configuration for credentials.
1237
+
1238
+ Returns:
1239
+ str: The concatenated text content of the document.
1240
+
1241
+ Raises:
1242
+ HttpError: If there's an error calling the Docs API.
1243
+ """
1244
+
1245
+ # --- 1. Extract Document ID from URL ---
1246
+ document_id = get_document_id_from_url(doc_url)
1247
+
1248
+ # --- 2. Set up credentials ---
1249
+ SCOPES = ['https://www.googleapis.com/auth/documents.readonly']
1250
+ credentials = get_google_credentials(sender_email, SCOPES, tool_config)
1251
+
1252
+ # --- 3. Build the Docs service and fetch the document ---
1253
+ try:
1254
+ service = build('docs', 'v1', credentials=credentials)
1255
+ document = service.documents().get(documentId=document_id).execute()
1256
+
1257
+ content = document.get('body', {}).get('content', [])
1258
+ text_parts: List[str] = []
1259
+ for element in content:
1260
+ paragraph = element.get('paragraph')
1261
+ if not paragraph:
1262
+ continue
1263
+ for elem in paragraph.get('elements', []):
1264
+ text_run = elem.get('textRun')
1265
+ if text_run:
1266
+ text_parts.append(text_run.get('content', ''))
1267
+
1268
+ return ''.join(text_parts)
1269
+
1270
+ except HttpError as e:
1271
+ logging.error(f"An error occurred while reading the Google Document: {e}")
1272
+ raise
1273
+
1274
+ def save_values_to_csv(values: List[List[str]], output_filename: str) -> str:
1275
+ """
1276
+ Saves a list of row values (list of lists) to a CSV file.
1277
+
1278
+ Args:
1279
+ values (List[List[str]]): Data to write to CSV.
1280
+ output_filename (str): CSV file name.
1281
+
1282
+ Returns:
1283
+ str: The path to the created CSV file.
1284
+ """
1285
+ # Create a unique filename to avoid collisions
1286
+ unique_filename = f"{uuid.uuid4()}_{output_filename}"
1287
+ local_file_path = os.path.join('/tmp', unique_filename)
1288
+
1289
+ # Write rows to CSV
1290
+ with open(local_file_path, 'w', newline='', encoding='utf-8') as csvfile:
1291
+ writer = csv.writer(csvfile)
1292
+ writer.writerows(values)
1293
+
1294
+ return local_file_path