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