arcade-google 0.1.6__py3-none-any.whl → 2.0.0__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.
- arcade_google/constants.py +24 -0
- arcade_google/critics.py +41 -0
- arcade_google/doc_to_html.py +99 -0
- arcade_google/doc_to_markdown.py +64 -0
- arcade_google/enums.py +0 -0
- arcade_google/exceptions.py +70 -0
- arcade_google/models.py +654 -0
- arcade_google/tools/__init__.py +96 -1
- arcade_google/tools/calendar.py +236 -32
- arcade_google/tools/contacts.py +96 -0
- arcade_google/tools/docs.py +24 -14
- arcade_google/tools/drive.py +256 -48
- arcade_google/tools/file_picker.py +54 -0
- arcade_google/tools/gmail.py +336 -116
- arcade_google/tools/sheets.py +144 -0
- arcade_google/utils.py +1564 -0
- arcade_google-2.0.0.dist-info/METADATA +27 -0
- arcade_google-2.0.0.dist-info/RECORD +21 -0
- {arcade_google-0.1.6.dist-info → arcade_google-2.0.0.dist-info}/WHEEL +1 -1
- arcade_google-2.0.0.dist-info/licenses/LICENSE +21 -0
- arcade_google/tools/models.py +0 -296
- arcade_google/tools/utils.py +0 -282
- arcade_google-0.1.6.dist-info/METADATA +0 -20
- arcade_google-0.1.6.dist-info/RECORD +0 -11
arcade_google/tools/__init__.py
CHANGED
|
@@ -1 +1,96 @@
|
|
|
1
|
-
|
|
1
|
+
from arcade_google.tools.calendar import (
|
|
2
|
+
create_event,
|
|
3
|
+
delete_event,
|
|
4
|
+
find_time_slots_when_everyone_is_free,
|
|
5
|
+
list_calendars,
|
|
6
|
+
list_events,
|
|
7
|
+
update_event,
|
|
8
|
+
)
|
|
9
|
+
from arcade_google.tools.contacts import (
|
|
10
|
+
create_contact,
|
|
11
|
+
search_contacts_by_email,
|
|
12
|
+
search_contacts_by_name,
|
|
13
|
+
)
|
|
14
|
+
from arcade_google.tools.docs import (
|
|
15
|
+
create_blank_document,
|
|
16
|
+
create_document_from_text,
|
|
17
|
+
get_document_by_id,
|
|
18
|
+
insert_text_at_end_of_document,
|
|
19
|
+
)
|
|
20
|
+
from arcade_google.tools.drive import (
|
|
21
|
+
get_file_tree_structure,
|
|
22
|
+
search_and_retrieve_documents,
|
|
23
|
+
search_documents,
|
|
24
|
+
)
|
|
25
|
+
from arcade_google.tools.file_picker import generate_google_file_picker_url
|
|
26
|
+
from arcade_google.tools.gmail import (
|
|
27
|
+
change_email_labels,
|
|
28
|
+
create_label,
|
|
29
|
+
delete_draft_email,
|
|
30
|
+
get_thread,
|
|
31
|
+
list_draft_emails,
|
|
32
|
+
list_emails,
|
|
33
|
+
list_emails_by_header,
|
|
34
|
+
list_labels,
|
|
35
|
+
list_threads,
|
|
36
|
+
reply_to_email,
|
|
37
|
+
search_threads,
|
|
38
|
+
send_draft_email,
|
|
39
|
+
send_email,
|
|
40
|
+
trash_email,
|
|
41
|
+
update_draft_email,
|
|
42
|
+
write_draft_email,
|
|
43
|
+
write_draft_reply_email,
|
|
44
|
+
)
|
|
45
|
+
from arcade_google.tools.sheets import (
|
|
46
|
+
create_spreadsheet,
|
|
47
|
+
get_spreadsheet,
|
|
48
|
+
write_to_cell,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
# Google Calendar
|
|
53
|
+
create_event,
|
|
54
|
+
delete_event,
|
|
55
|
+
find_time_slots_when_everyone_is_free,
|
|
56
|
+
list_calendars,
|
|
57
|
+
list_events,
|
|
58
|
+
update_event,
|
|
59
|
+
# Google Contacts
|
|
60
|
+
create_contact,
|
|
61
|
+
search_contacts_by_email,
|
|
62
|
+
search_contacts_by_name,
|
|
63
|
+
# Google Docs
|
|
64
|
+
create_blank_document,
|
|
65
|
+
create_document_from_text,
|
|
66
|
+
get_document_by_id,
|
|
67
|
+
insert_text_at_end_of_document,
|
|
68
|
+
# Google Drive
|
|
69
|
+
"get_file_tree_structure",
|
|
70
|
+
"search_and_retrieve_documents",
|
|
71
|
+
"search_documents",
|
|
72
|
+
# Google File Picker
|
|
73
|
+
generate_google_file_picker_url,
|
|
74
|
+
# Google Gmail
|
|
75
|
+
change_email_labels,
|
|
76
|
+
create_label,
|
|
77
|
+
delete_draft_email,
|
|
78
|
+
get_thread,
|
|
79
|
+
list_draft_emails,
|
|
80
|
+
list_emails,
|
|
81
|
+
list_emails_by_header,
|
|
82
|
+
list_labels,
|
|
83
|
+
list_threads,
|
|
84
|
+
reply_to_email,
|
|
85
|
+
search_threads,
|
|
86
|
+
send_draft_email,
|
|
87
|
+
send_email,
|
|
88
|
+
trash_email,
|
|
89
|
+
update_draft_email,
|
|
90
|
+
write_draft_email,
|
|
91
|
+
write_draft_reply_email,
|
|
92
|
+
# Google Sheets
|
|
93
|
+
create_spreadsheet,
|
|
94
|
+
get_spreadsheet,
|
|
95
|
+
write_to_cell,
|
|
96
|
+
]
|
arcade_google/tools/calendar.py
CHANGED
|
@@ -1,15 +1,65 @@
|
|
|
1
|
+
import json
|
|
1
2
|
from datetime import datetime, timedelta
|
|
2
|
-
from typing import Annotated
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
3
5
|
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
+
from arcade_tdk import ToolContext, tool
|
|
7
|
+
from arcade_tdk.auth import Google
|
|
8
|
+
from arcade_tdk.errors import RetryableToolError
|
|
6
9
|
from googleapiclient.errors import HttpError
|
|
7
10
|
|
|
8
|
-
from
|
|
9
|
-
from
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
from arcade_google.models import EventVisibility, SendUpdatesOptions
|
|
12
|
+
from arcade_google.utils import (
|
|
13
|
+
build_calendar_service,
|
|
14
|
+
build_oauth_service,
|
|
15
|
+
compute_free_time_intersection,
|
|
16
|
+
parse_datetime,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@tool(
|
|
21
|
+
requires_auth=Google(
|
|
22
|
+
scopes=[
|
|
23
|
+
"https://www.googleapis.com/auth/calendar.readonly",
|
|
24
|
+
"https://www.googleapis.com/auth/calendar.events",
|
|
25
|
+
]
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
async def list_calendars(
|
|
29
|
+
context: ToolContext,
|
|
30
|
+
max_results: Annotated[
|
|
31
|
+
int, "The maximum number of calendars to return. Up to 250 calendars, defaults to 10."
|
|
32
|
+
] = 10,
|
|
33
|
+
show_deleted: Annotated[bool, "Whether to show deleted calendars. Defaults to False"] = False,
|
|
34
|
+
show_hidden: Annotated[bool, "Whether to show hidden calendars. Defaults to False"] = False,
|
|
35
|
+
next_page_token: Annotated[
|
|
36
|
+
str | None, "The token to retrieve the next page of calendars. Optional."
|
|
37
|
+
] = None,
|
|
38
|
+
) -> Annotated[dict, "A dictionary containing the calendars accessible by the end user"]:
|
|
39
|
+
"""
|
|
40
|
+
List all calendars accessible by the user.
|
|
41
|
+
"""
|
|
42
|
+
max_results = max(1, min(max_results, 250))
|
|
43
|
+
service = build_calendar_service(context.get_auth_token_or_empty())
|
|
44
|
+
calendars = (
|
|
45
|
+
service.calendarList()
|
|
46
|
+
.list(
|
|
47
|
+
pageToken=next_page_token,
|
|
48
|
+
showDeleted=show_deleted,
|
|
49
|
+
showHidden=show_hidden,
|
|
50
|
+
maxResults=max_results,
|
|
51
|
+
)
|
|
52
|
+
.execute()
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
items = calendars.get("items", [])
|
|
56
|
+
keys = ["description", "id", "summary", "timeZone"]
|
|
57
|
+
relevant_items = [{k: i.get(k) for k in keys if i.get(k)} for i in items]
|
|
58
|
+
return {
|
|
59
|
+
"next_page_token": calendars.get("nextPageToken"),
|
|
60
|
+
"num_calendars": len(relevant_items),
|
|
61
|
+
"calendars": relevant_items,
|
|
62
|
+
}
|
|
13
63
|
|
|
14
64
|
|
|
15
65
|
@tool(
|
|
@@ -44,7 +94,7 @@ async def create_event(
|
|
|
44
94
|
) -> Annotated[dict, "A dictionary containing the created event details"]:
|
|
45
95
|
"""Create a new event/meeting/sync/meetup in the specified calendar."""
|
|
46
96
|
|
|
47
|
-
service =
|
|
97
|
+
service = build_calendar_service(context.get_auth_token_or_empty())
|
|
48
98
|
|
|
49
99
|
# Get the calendar's time zone
|
|
50
100
|
calendar = service.calendars().get(calendarId=calendar_id).execute()
|
|
@@ -54,7 +104,7 @@ async def create_event(
|
|
|
54
104
|
start_dt = parse_datetime(start_datetime, time_zone)
|
|
55
105
|
end_dt = parse_datetime(end_datetime, time_zone)
|
|
56
106
|
|
|
57
|
-
event = {
|
|
107
|
+
event: dict[str, Any] = {
|
|
58
108
|
"summary": summary,
|
|
59
109
|
"description": description,
|
|
60
110
|
"location": location,
|
|
@@ -82,11 +132,13 @@ async def list_events(
|
|
|
82
132
|
context: ToolContext,
|
|
83
133
|
min_end_datetime: Annotated[
|
|
84
134
|
str,
|
|
85
|
-
"Filter by events that end on or after this datetime in ISO 8601 format,
|
|
135
|
+
"Filter by events that end on or after this datetime in ISO 8601 format, "
|
|
136
|
+
"e.g., '2024-09-15T09:00:00'.",
|
|
86
137
|
],
|
|
87
138
|
max_start_datetime: Annotated[
|
|
88
139
|
str,
|
|
89
|
-
"Filter by events that start before this datetime in ISO 8601 format,
|
|
140
|
+
"Filter by events that start before this datetime in ISO 8601 format, "
|
|
141
|
+
"e.g., '2024-09-16T17:00:00'.",
|
|
90
142
|
],
|
|
91
143
|
calendar_id: Annotated[str, "The ID of the calendar to list events from"] = "primary",
|
|
92
144
|
max_results: Annotated[int, "The maximum number of events to return"] = 10,
|
|
@@ -98,14 +150,15 @@ async def list_events(
|
|
|
98
150
|
max_start_datetime serves as the upper bound (exclusive) for an event's start time.
|
|
99
151
|
|
|
100
152
|
For example:
|
|
101
|
-
If min_end_datetime is set to 2024-09-15T09:00:00 and max_start_datetime
|
|
102
|
-
the function will return events that:
|
|
153
|
+
If min_end_datetime is set to 2024-09-15T09:00:00 and max_start_datetime
|
|
154
|
+
is set to 2024-09-16T17:00:00, the function will return events that:
|
|
103
155
|
1. End after 09:00 on September 15, 2024 (exclusive)
|
|
104
156
|
2. Start before 17:00 on September 16, 2024 (exclusive)
|
|
105
|
-
This means an event starting at 08:00 on September 15 and
|
|
106
|
-
|
|
157
|
+
This means an event starting at 08:00 on September 15 and
|
|
158
|
+
ending at 10:00 on September 15 would be included, but an
|
|
159
|
+
event starting at 17:00 on September 16 would not be included.
|
|
107
160
|
"""
|
|
108
|
-
service =
|
|
161
|
+
service = build_calendar_service(context.get_auth_token_or_empty())
|
|
109
162
|
|
|
110
163
|
# Get the calendar's time zone
|
|
111
164
|
calendar = service.calendars().get(calendarId=calendar_id).execute()
|
|
@@ -157,7 +210,7 @@ async def list_events(
|
|
|
157
210
|
|
|
158
211
|
@tool(
|
|
159
212
|
requires_auth=Google(
|
|
160
|
-
scopes=["https://www.googleapis.com/auth/calendar"],
|
|
213
|
+
scopes=["https://www.googleapis.com/auth/calendar.events"],
|
|
161
214
|
)
|
|
162
215
|
)
|
|
163
216
|
async def update_event(
|
|
@@ -165,7 +218,8 @@ async def update_event(
|
|
|
165
218
|
event_id: Annotated[str, "The ID of the event to update"],
|
|
166
219
|
updated_start_datetime: Annotated[
|
|
167
220
|
str | None,
|
|
168
|
-
"The updated datetime that the event starts in ISO 8601 format,
|
|
221
|
+
"The updated datetime that the event starts in ISO 8601 format, "
|
|
222
|
+
"e.g., '2024-12-31T15:30:00'.",
|
|
169
223
|
] = None,
|
|
170
224
|
updated_end_datetime: Annotated[
|
|
171
225
|
str | None,
|
|
@@ -180,26 +234,31 @@ async def update_event(
|
|
|
180
234
|
updated_visibility: Annotated[EventVisibility | None, "The visibility of the event"] = None,
|
|
181
235
|
attendee_emails_to_add: Annotated[
|
|
182
236
|
list[str] | None,
|
|
183
|
-
"The list of attendee emails to add. Must be valid email addresses
|
|
237
|
+
"The list of attendee emails to add. Must be valid email addresses "
|
|
238
|
+
"e.g., username@domain.com.",
|
|
184
239
|
] = None,
|
|
185
240
|
attendee_emails_to_remove: Annotated[
|
|
186
241
|
list[str] | None,
|
|
187
|
-
"The list of attendee emails to remove. Must be valid email addresses
|
|
242
|
+
"The list of attendee emails to remove. Must be valid email addresses "
|
|
243
|
+
"e.g., username@domain.com.",
|
|
188
244
|
] = None,
|
|
189
245
|
send_updates: Annotated[
|
|
190
|
-
SendUpdatesOptions,
|
|
246
|
+
SendUpdatesOptions,
|
|
247
|
+
"Should attendees be notified of the update? (none, all, external_only)",
|
|
191
248
|
] = SendUpdatesOptions.ALL,
|
|
192
249
|
) -> Annotated[
|
|
193
250
|
str,
|
|
194
|
-
"A string containing the updated event details, including the event ID, update timestamp,
|
|
251
|
+
"A string containing the updated event details, including the event ID, update timestamp, "
|
|
252
|
+
"and a link to view the updated event.",
|
|
195
253
|
]:
|
|
196
254
|
"""
|
|
197
255
|
Update an existing event in the specified calendar with the provided details.
|
|
198
256
|
Only the provided fields will be updated; others will remain unchanged.
|
|
199
257
|
|
|
200
|
-
`updated_start_datetime` and `updated_end_datetime` are
|
|
258
|
+
`updated_start_datetime` and `updated_end_datetime` are
|
|
259
|
+
independent and can be provided separately.
|
|
201
260
|
"""
|
|
202
|
-
service =
|
|
261
|
+
service = build_calendar_service(context.get_auth_token_or_empty())
|
|
203
262
|
|
|
204
263
|
calendar = service.calendars().get(calendarId="primary").execute()
|
|
205
264
|
time_zone = calendar["timeZone"]
|
|
@@ -221,16 +280,21 @@ async def update_event(
|
|
|
221
280
|
)
|
|
222
281
|
raise RetryableToolError(
|
|
223
282
|
f"Event with ID {event_id} not found.",
|
|
224
|
-
additional_prompt_content=
|
|
283
|
+
additional_prompt_content=(
|
|
284
|
+
f"Here is a list of valid events. The event_id parameter must match one of these: "
|
|
285
|
+
f"{valid_events_with_id}"
|
|
286
|
+
),
|
|
225
287
|
retry_after_ms=1000,
|
|
226
|
-
developer_message=
|
|
288
|
+
developer_message=(
|
|
289
|
+
f"Event with ID {event_id} not found. Please try again with a valid event ID."
|
|
290
|
+
),
|
|
227
291
|
)
|
|
228
292
|
|
|
229
293
|
update_fields = {
|
|
230
|
-
"start": {"dateTime": updated_start_datetime
|
|
294
|
+
"start": {"dateTime": updated_start_datetime, "timeZone": time_zone}
|
|
231
295
|
if updated_start_datetime
|
|
232
296
|
else None,
|
|
233
|
-
"end": {"dateTime": updated_end_datetime
|
|
297
|
+
"end": {"dateTime": updated_end_datetime, "timeZone": time_zone}
|
|
234
298
|
if updated_end_datetime
|
|
235
299
|
else None,
|
|
236
300
|
"calendarId": updated_calendar_id,
|
|
@@ -272,7 +336,10 @@ async def update_event(
|
|
|
272
336
|
)
|
|
273
337
|
.execute()
|
|
274
338
|
)
|
|
275
|
-
return
|
|
339
|
+
return (
|
|
340
|
+
f"Event with ID {event_id} successfully updated at {updated_event['updated']}. "
|
|
341
|
+
f"View updated event at {updated_event['htmlLink']}"
|
|
342
|
+
)
|
|
276
343
|
|
|
277
344
|
|
|
278
345
|
@tool(
|
|
@@ -289,7 +356,7 @@ async def delete_event(
|
|
|
289
356
|
] = SendUpdatesOptions.ALL,
|
|
290
357
|
) -> Annotated[str, "A string containing the deletion confirmation message"]:
|
|
291
358
|
"""Delete an event from Google Calendar."""
|
|
292
|
-
service =
|
|
359
|
+
service = build_calendar_service(context.get_auth_token_or_empty())
|
|
293
360
|
|
|
294
361
|
service.events().delete(
|
|
295
362
|
calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates.value
|
|
@@ -303,4 +370,141 @@ async def delete_event(
|
|
|
303
370
|
elif send_updates == SendUpdatesOptions.NONE:
|
|
304
371
|
notification_message = "No notifications were sent to attendees."
|
|
305
372
|
|
|
306
|
-
return
|
|
373
|
+
return (
|
|
374
|
+
f"Event with ID '{event_id}' successfully deleted from calendar '{calendar_id}'. "
|
|
375
|
+
f"{notification_message}"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# TODO: would be nice to have a "min_slot_duration" parameter
|
|
380
|
+
# TODO: find a way to have "include_weekends" parameter without confusing LLMs
|
|
381
|
+
@tool(
|
|
382
|
+
requires_auth=Google(
|
|
383
|
+
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
async def find_time_slots_when_everyone_is_free(
|
|
387
|
+
context: ToolContext,
|
|
388
|
+
email_addresses: Annotated[
|
|
389
|
+
list[str] | None,
|
|
390
|
+
"The list of email addresses from people in the same organization domain (apart from the "
|
|
391
|
+
"currently logged in user) to search for free time slots. Defaults to None, which will "
|
|
392
|
+
"return free time slots for the current user only.",
|
|
393
|
+
] = None,
|
|
394
|
+
start_date: Annotated[
|
|
395
|
+
str | None,
|
|
396
|
+
"The start date to search for time slots in the format 'YYYY-MM-DD'. Defaults to today's "
|
|
397
|
+
"date. It will search starting from this date at the time 00:00:00.",
|
|
398
|
+
] = None,
|
|
399
|
+
end_date: Annotated[
|
|
400
|
+
str | None,
|
|
401
|
+
"The end date to search for time slots in the format 'YYYY-MM-DD'. Defaults to seven days "
|
|
402
|
+
"from the start date. It will search until this date at the time 23:59:59.",
|
|
403
|
+
] = None,
|
|
404
|
+
start_time_boundary: Annotated[
|
|
405
|
+
str,
|
|
406
|
+
"Will return free slots in any given day starting from this time in the format 'HH:MM'. "
|
|
407
|
+
"Defaults to '08:00', which is a usual business hour start time.",
|
|
408
|
+
] = "08:00",
|
|
409
|
+
end_time_boundary: Annotated[
|
|
410
|
+
str,
|
|
411
|
+
"Will return free slots in any given day until this time in the format 'HH:MM'. "
|
|
412
|
+
"Defaults to '18:00', which is a usual business hour end time.",
|
|
413
|
+
] = "18:00",
|
|
414
|
+
) -> Annotated[
|
|
415
|
+
dict,
|
|
416
|
+
"A dictionary with the free slots and the timezone in which time slots are represented.",
|
|
417
|
+
]:
|
|
418
|
+
"""
|
|
419
|
+
Provides time slots when everyone is free within a given date range and time boundaries.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
# Build google api services
|
|
423
|
+
oauth_service = build_oauth_service(context.get_auth_token_or_empty())
|
|
424
|
+
calendar_service = build_calendar_service(context.get_auth_token_or_empty())
|
|
425
|
+
|
|
426
|
+
email_addresses = email_addresses or []
|
|
427
|
+
|
|
428
|
+
if isinstance(email_addresses, str):
|
|
429
|
+
email_addresses = [email_addresses]
|
|
430
|
+
|
|
431
|
+
# Add the currently logged in user to the list of email addresses
|
|
432
|
+
user_info = oauth_service.userinfo().get().execute()
|
|
433
|
+
if user_info["email"] not in email_addresses:
|
|
434
|
+
email_addresses.append(user_info["email"])
|
|
435
|
+
|
|
436
|
+
# Get the timezone of the currently logged in user
|
|
437
|
+
calendar = calendar_service.calendars().get(calendarId="primary").execute()
|
|
438
|
+
timezone_name = calendar.get("timeZone")
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
tz = ZoneInfo(timezone_name)
|
|
442
|
+
# If the calendar timezone name is not supported by Python's zoneinfo, use UTC
|
|
443
|
+
except ZoneInfoNotFoundError:
|
|
444
|
+
timezone_name = "UTC"
|
|
445
|
+
tz = ZoneInfo("UTC")
|
|
446
|
+
|
|
447
|
+
# Set default start and end dates, if not provided by the caller
|
|
448
|
+
start_date = start_date or datetime.now(tz=tz).date().isoformat()
|
|
449
|
+
end_date = end_date or (datetime.now(tz=tz).date() + timedelta(days=7)).isoformat()
|
|
450
|
+
|
|
451
|
+
# Parse start and end dates to datetime objects
|
|
452
|
+
start_datetime = datetime.strptime(start_date, "%Y-%m-%d").replace(
|
|
453
|
+
hour=0, minute=0, second=0, microsecond=0, tzinfo=tz
|
|
454
|
+
)
|
|
455
|
+
end_datetime = datetime.strptime(end_date, "%Y-%m-%d").replace(
|
|
456
|
+
hour=23, minute=59, second=59, microsecond=0, tzinfo=tz
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Get the busy slots from the calendars of the users
|
|
460
|
+
freebusy_response = (
|
|
461
|
+
calendar_service.freebusy()
|
|
462
|
+
.query(
|
|
463
|
+
body={
|
|
464
|
+
"timeMin": start_datetime.isoformat(),
|
|
465
|
+
"timeMax": end_datetime.isoformat(),
|
|
466
|
+
"timeZone": timezone_name,
|
|
467
|
+
"items": [{"id": email_address} for email_address in email_addresses],
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
.execute()
|
|
471
|
+
)
|
|
472
|
+
busy_slots = freebusy_response["calendars"]
|
|
473
|
+
|
|
474
|
+
response_errors = []
|
|
475
|
+
|
|
476
|
+
for email in email_addresses:
|
|
477
|
+
if "errors" not in busy_slots[email]:
|
|
478
|
+
continue
|
|
479
|
+
errors = busy_slots[email]["errors"]
|
|
480
|
+
for error in errors:
|
|
481
|
+
response_errors.append(
|
|
482
|
+
f"Error retrieving free slots from calendar of '{email}': "
|
|
483
|
+
f"{error.get('reason', 'not determined')}"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if response_errors:
|
|
487
|
+
raise RetryableToolError(
|
|
488
|
+
"Error retrieving free slots from calendars of one or more users.",
|
|
489
|
+
additional_prompt_content=json.dumps(response_errors),
|
|
490
|
+
retry_after_ms=1000,
|
|
491
|
+
developer_message="Error retrieving free slots from calendars of one or more users.",
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Compute the free slots
|
|
495
|
+
free_slots = compute_free_time_intersection(
|
|
496
|
+
busy_data=busy_slots,
|
|
497
|
+
global_start=start_datetime,
|
|
498
|
+
global_end=end_datetime,
|
|
499
|
+
start_time_boundary=datetime.strptime(start_time_boundary, "%H:%M")
|
|
500
|
+
.time()
|
|
501
|
+
.replace(tzinfo=tz),
|
|
502
|
+
end_time_boundary=datetime.strptime(end_time_boundary, "%H:%M").time().replace(tzinfo=tz),
|
|
503
|
+
include_weekends=True,
|
|
504
|
+
tz=tz,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
"free_slots": free_slots,
|
|
509
|
+
"timezone": timezone_name,
|
|
510
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from arcade_tdk import ToolContext, tool
|
|
5
|
+
from arcade_tdk.auth import Google
|
|
6
|
+
|
|
7
|
+
from arcade_google.constants import DEFAULT_SEARCH_CONTACTS_LIMIT
|
|
8
|
+
from arcade_google.utils import build_people_service, search_contacts
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _warmup_cache(service) -> None: # type: ignore[no-untyped-def]
|
|
12
|
+
"""
|
|
13
|
+
Warm-up the search cache for contacts by sending a request with an empty query.
|
|
14
|
+
This ensures that the lazy cache is updated for both primary contacts and other contacts.
|
|
15
|
+
This is unfortunately a real thing: https://developers.google.com/people/v1/contacts#search_the_users_contacts
|
|
16
|
+
"""
|
|
17
|
+
service.people().searchContacts(query="", pageSize=1, readMask="names,emailAddresses").execute()
|
|
18
|
+
await asyncio.sleep(3) # TODO experiment with this value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts.readonly"]))
|
|
22
|
+
async def search_contacts_by_email(
|
|
23
|
+
context: ToolContext,
|
|
24
|
+
email: Annotated[str, "The email address to search for"],
|
|
25
|
+
limit: Annotated[
|
|
26
|
+
int | None,
|
|
27
|
+
"The maximum number of contacts to return (30 is the max allowed by Google API)",
|
|
28
|
+
] = DEFAULT_SEARCH_CONTACTS_LIMIT,
|
|
29
|
+
) -> Annotated[dict, "A dictionary containing the list of matching contacts"]:
|
|
30
|
+
"""
|
|
31
|
+
Search the user's contacts in Google Contacts by email address.
|
|
32
|
+
"""
|
|
33
|
+
service = build_people_service(context.get_auth_token_or_empty())
|
|
34
|
+
# Warm-up the cache before performing search.
|
|
35
|
+
# TODO: Ideally we should warmup only if this user (or google domain?) hasn't warmed up recently
|
|
36
|
+
await _warmup_cache(service)
|
|
37
|
+
|
|
38
|
+
return {"contacts": search_contacts(service, email, limit)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts.readonly"]))
|
|
42
|
+
async def search_contacts_by_name(
|
|
43
|
+
context: ToolContext,
|
|
44
|
+
name: Annotated[str, "The full name to search for"],
|
|
45
|
+
limit: Annotated[
|
|
46
|
+
int | None,
|
|
47
|
+
"The maximum number of contacts to return (30 is the max allowed by Google API)",
|
|
48
|
+
] = DEFAULT_SEARCH_CONTACTS_LIMIT,
|
|
49
|
+
) -> Annotated[dict, "A dictionary containing the list of matching contacts"]:
|
|
50
|
+
"""
|
|
51
|
+
Search the user's contacts in Google Contacts by name.
|
|
52
|
+
"""
|
|
53
|
+
service = build_people_service(context.get_auth_token_or_empty())
|
|
54
|
+
# Warm-up the cache before performing search.
|
|
55
|
+
# TODO: Ideally we should warmup only if this user (or google domain?) hasn't warmed up recently
|
|
56
|
+
await _warmup_cache(service)
|
|
57
|
+
return {"contacts": search_contacts(service, name, limit)}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@tool(requires_auth=Google(scopes=["https://www.googleapis.com/auth/contacts"]))
|
|
61
|
+
async def create_contact(
|
|
62
|
+
context: ToolContext,
|
|
63
|
+
given_name: Annotated[str, "The given name of the contact"],
|
|
64
|
+
family_name: Annotated[str | None, "The optional family name of the contact"],
|
|
65
|
+
email: Annotated[str | None, "The optional email address of the contact"],
|
|
66
|
+
) -> Annotated[dict, "A dictionary containing the details of the created contact"]:
|
|
67
|
+
"""
|
|
68
|
+
Create a new contact record in Google Contacts.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
```
|
|
72
|
+
create_contact(given_name="Alice")
|
|
73
|
+
create_contact(given_name="Alice", family_name="Smith")
|
|
74
|
+
create_contact(given_name="Alice", email="alice@example.com")
|
|
75
|
+
```
|
|
76
|
+
"""
|
|
77
|
+
# Build the People API service
|
|
78
|
+
service = build_people_service(context.get_auth_token_or_empty())
|
|
79
|
+
|
|
80
|
+
# Construct the person payload with the specified names
|
|
81
|
+
name_body = {"givenName": given_name}
|
|
82
|
+
if family_name:
|
|
83
|
+
name_body["familyName"] = family_name
|
|
84
|
+
contact_body = {"names": [name_body]}
|
|
85
|
+
if email:
|
|
86
|
+
contact_body["emailAddresses"] = [{"value": email, "type": "work"}]
|
|
87
|
+
|
|
88
|
+
# Create the contact. The personFields parameter specifies what information
|
|
89
|
+
# should be returned. Here, we return names and emailAddresses.
|
|
90
|
+
created_contact = (
|
|
91
|
+
service.people()
|
|
92
|
+
.createContact(body=contact_body, personFields="names,emailAddresses")
|
|
93
|
+
.execute()
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return {"contact": created_contact}
|
arcade_google/tools/docs.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from typing import Annotated
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
from
|
|
5
|
-
|
|
3
|
+
from arcade_tdk import ToolContext, tool
|
|
4
|
+
from arcade_tdk.auth import Google
|
|
5
|
+
|
|
6
|
+
from arcade_google.utils import build_docs_service
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/get
|
|
@@ -11,7 +12,7 @@ from arcade_google.tools.utils import build_docs_service
|
|
|
11
12
|
@tool(
|
|
12
13
|
requires_auth=Google(
|
|
13
14
|
scopes=[
|
|
14
|
-
"https://www.googleapis.com/auth/
|
|
15
|
+
"https://www.googleapis.com/auth/drive.file",
|
|
15
16
|
],
|
|
16
17
|
)
|
|
17
18
|
)
|
|
@@ -22,13 +23,15 @@ async def get_document_by_id(
|
|
|
22
23
|
"""
|
|
23
24
|
Get the latest version of the specified Google Docs document.
|
|
24
25
|
"""
|
|
25
|
-
service = build_docs_service(
|
|
26
|
+
service = build_docs_service(
|
|
27
|
+
context.authorization.token if context.authorization and context.authorization.token else ""
|
|
28
|
+
)
|
|
26
29
|
|
|
27
30
|
# Execute the documents().get() method. Returns a Document object
|
|
28
31
|
# https://developers.google.com/docs/api/reference/rest/v1/documents#Document
|
|
29
32
|
request = service.documents().get(documentId=document_id)
|
|
30
33
|
response = request.execute()
|
|
31
|
-
return response
|
|
34
|
+
return dict(response)
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate
|
|
@@ -36,7 +39,7 @@ async def get_document_by_id(
|
|
|
36
39
|
@tool(
|
|
37
40
|
requires_auth=Google(
|
|
38
41
|
scopes=[
|
|
39
|
-
"https://www.googleapis.com/auth/
|
|
42
|
+
"https://www.googleapis.com/auth/drive.file",
|
|
40
43
|
],
|
|
41
44
|
)
|
|
42
45
|
)
|
|
@@ -52,7 +55,9 @@ async def insert_text_at_end_of_document(
|
|
|
52
55
|
|
|
53
56
|
end_index = document["body"]["content"][-1]["endIndex"]
|
|
54
57
|
|
|
55
|
-
service = build_docs_service(
|
|
58
|
+
service = build_docs_service(
|
|
59
|
+
context.authorization.token if context.authorization and context.authorization.token else ""
|
|
60
|
+
)
|
|
56
61
|
|
|
57
62
|
requests = [
|
|
58
63
|
{
|
|
@@ -72,7 +77,7 @@ async def insert_text_at_end_of_document(
|
|
|
72
77
|
.execute()
|
|
73
78
|
)
|
|
74
79
|
|
|
75
|
-
return response
|
|
80
|
+
return dict(response)
|
|
76
81
|
|
|
77
82
|
|
|
78
83
|
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/create
|
|
@@ -80,7 +85,7 @@ async def insert_text_at_end_of_document(
|
|
|
80
85
|
@tool(
|
|
81
86
|
requires_auth=Google(
|
|
82
87
|
scopes=[
|
|
83
|
-
"https://www.googleapis.com/auth/
|
|
88
|
+
"https://www.googleapis.com/auth/drive.file",
|
|
84
89
|
],
|
|
85
90
|
)
|
|
86
91
|
)
|
|
@@ -90,7 +95,9 @@ async def create_blank_document(
|
|
|
90
95
|
"""
|
|
91
96
|
Create a blank Google Docs document with the specified title.
|
|
92
97
|
"""
|
|
93
|
-
service = build_docs_service(
|
|
98
|
+
service = build_docs_service(
|
|
99
|
+
context.authorization.token if context.authorization and context.authorization.token else ""
|
|
100
|
+
)
|
|
94
101
|
|
|
95
102
|
body = {"title": title}
|
|
96
103
|
|
|
@@ -106,11 +113,12 @@ async def create_blank_document(
|
|
|
106
113
|
|
|
107
114
|
|
|
108
115
|
# Uses https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate
|
|
109
|
-
# Example `arcade chat` query:
|
|
116
|
+
# Example `arcade chat` query:
|
|
117
|
+
# `create document with title "My New Document" and text content "Hello, World!"`
|
|
110
118
|
@tool(
|
|
111
119
|
requires_auth=Google(
|
|
112
120
|
scopes=[
|
|
113
|
-
"https://www.googleapis.com/auth/
|
|
121
|
+
"https://www.googleapis.com/auth/drive.file",
|
|
114
122
|
],
|
|
115
123
|
)
|
|
116
124
|
)
|
|
@@ -125,7 +133,9 @@ async def create_document_from_text(
|
|
|
125
133
|
# First, create a blank document
|
|
126
134
|
document = await create_blank_document(context, title)
|
|
127
135
|
|
|
128
|
-
service = build_docs_service(
|
|
136
|
+
service = build_docs_service(
|
|
137
|
+
context.authorization.token if context.authorization and context.authorization.token else ""
|
|
138
|
+
)
|
|
129
139
|
|
|
130
140
|
requests = [
|
|
131
141
|
{
|