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.
- dhisana/__init__.py +1 -0
- dhisana/cli/__init__.py +1 -0
- dhisana/cli/cli.py +20 -0
- dhisana/cli/datasets.py +27 -0
- dhisana/cli/models.py +26 -0
- dhisana/cli/predictions.py +20 -0
- dhisana/schemas/__init__.py +1 -0
- dhisana/schemas/common.py +399 -0
- dhisana/schemas/sales.py +965 -0
- dhisana/ui/__init__.py +1 -0
- dhisana/ui/components.py +472 -0
- dhisana/utils/__init__.py +1 -0
- dhisana/utils/add_mapping.py +352 -0
- dhisana/utils/agent_tools.py +51 -0
- dhisana/utils/apollo_tools.py +1597 -0
- dhisana/utils/assistant_tool_tag.py +4 -0
- dhisana/utils/built_with_api_tools.py +282 -0
- dhisana/utils/cache_output_tools.py +98 -0
- dhisana/utils/cache_output_tools_local.py +78 -0
- dhisana/utils/check_email_validity_tools.py +717 -0
- dhisana/utils/check_for_intent_signal.py +107 -0
- dhisana/utils/check_linkedin_url_validity.py +209 -0
- dhisana/utils/clay_tools.py +43 -0
- dhisana/utils/clean_properties.py +135 -0
- dhisana/utils/company_utils.py +60 -0
- dhisana/utils/compose_salesnav_query.py +259 -0
- dhisana/utils/compose_search_query.py +759 -0
- dhisana/utils/compose_three_step_workflow.py +234 -0
- dhisana/utils/composite_tools.py +137 -0
- dhisana/utils/dataframe_tools.py +237 -0
- dhisana/utils/domain_parser.py +45 -0
- dhisana/utils/email_body_utils.py +72 -0
- dhisana/utils/email_parse_helpers.py +132 -0
- dhisana/utils/email_provider.py +375 -0
- dhisana/utils/enrich_lead_information.py +933 -0
- dhisana/utils/extract_email_content_for_llm.py +101 -0
- dhisana/utils/fetch_openai_config.py +129 -0
- dhisana/utils/field_validators.py +426 -0
- dhisana/utils/g2_tools.py +104 -0
- dhisana/utils/generate_content.py +41 -0
- dhisana/utils/generate_custom_message.py +271 -0
- dhisana/utils/generate_email.py +278 -0
- dhisana/utils/generate_email_response.py +465 -0
- dhisana/utils/generate_flow.py +102 -0
- dhisana/utils/generate_leads_salesnav.py +303 -0
- dhisana/utils/generate_linkedin_connect_message.py +224 -0
- dhisana/utils/generate_linkedin_response_message.py +317 -0
- dhisana/utils/generate_structured_output_internal.py +462 -0
- dhisana/utils/google_custom_search.py +267 -0
- dhisana/utils/google_oauth_tools.py +727 -0
- dhisana/utils/google_workspace_tools.py +1294 -0
- dhisana/utils/hubspot_clearbit.py +96 -0
- dhisana/utils/hubspot_crm_tools.py +2440 -0
- dhisana/utils/instantly_tools.py +149 -0
- dhisana/utils/linkedin_crawler.py +168 -0
- dhisana/utils/lusha_tools.py +333 -0
- dhisana/utils/mailgun_tools.py +156 -0
- dhisana/utils/mailreach_tools.py +123 -0
- dhisana/utils/microsoft365_tools.py +455 -0
- dhisana/utils/openai_assistant_and_file_utils.py +267 -0
- dhisana/utils/openai_helpers.py +977 -0
- dhisana/utils/openapi_spec_to_tools.py +45 -0
- dhisana/utils/openapi_tool/__init__.py +1 -0
- dhisana/utils/openapi_tool/api_models.py +633 -0
- dhisana/utils/openapi_tool/convert_openai_spec_to_tool.py +271 -0
- dhisana/utils/openapi_tool/openapi_tool.py +319 -0
- dhisana/utils/parse_linkedin_messages_txt.py +100 -0
- dhisana/utils/profile.py +37 -0
- dhisana/utils/proxy_curl_tools.py +1226 -0
- dhisana/utils/proxycurl_search_leads.py +426 -0
- dhisana/utils/python_function_to_tools.py +83 -0
- dhisana/utils/research_lead.py +176 -0
- dhisana/utils/sales_navigator_crawler.py +1103 -0
- dhisana/utils/salesforce_crm_tools.py +477 -0
- dhisana/utils/search_router.py +131 -0
- dhisana/utils/search_router_jobs.py +51 -0
- dhisana/utils/sendgrid_tools.py +162 -0
- dhisana/utils/serarch_router_local_business.py +75 -0
- dhisana/utils/serpapi_additional_tools.py +290 -0
- dhisana/utils/serpapi_google_jobs.py +117 -0
- dhisana/utils/serpapi_google_search.py +188 -0
- dhisana/utils/serpapi_local_business_search.py +129 -0
- dhisana/utils/serpapi_search_tools.py +852 -0
- dhisana/utils/serperdev_google_jobs.py +125 -0
- dhisana/utils/serperdev_local_business.py +154 -0
- dhisana/utils/serperdev_search.py +233 -0
- dhisana/utils/smtp_email_tools.py +582 -0
- dhisana/utils/test_connect.py +2087 -0
- dhisana/utils/trasform_json.py +173 -0
- dhisana/utils/web_download_parse_tools.py +189 -0
- dhisana/utils/workflow_code_model.py +5 -0
- dhisana/utils/zoominfo_tools.py +357 -0
- dhisana/workflow/__init__.py +1 -0
- dhisana/workflow/agent.py +18 -0
- dhisana/workflow/flow.py +44 -0
- dhisana/workflow/task.py +43 -0
- dhisana/workflow/test.py +90 -0
- dhisana-0.0.1.dev243.dist-info/METADATA +43 -0
- dhisana-0.0.1.dev243.dist-info/RECORD +102 -0
- dhisana-0.0.1.dev243.dist-info/WHEEL +5 -0
- dhisana-0.0.1.dev243.dist-info/entry_points.txt +2 -0
- 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
|