## Google File Picker The Google Picker lets users select or upload Google Drive files. Users can grant permission to your apps to access their Drive data, providing a secure and authorized way to interact with their files. The `generate_google_file_picker_url` returns a URL to a Google File Picker for the user.
510 lines
18 KiB
Python
510 lines
18 KiB
Python
import json
|
|
from datetime import datetime, timedelta
|
|
from typing import Annotated, Any
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
from arcade.sdk import ToolContext, tool
|
|
from arcade.sdk.auth import Google
|
|
from arcade.sdk.errors import RetryableToolError
|
|
from googleapiclient.errors import HttpError
|
|
|
|
from arcade_google.models import EventVisibility, SendUpdatesOptions
|
|
from arcade_google.utils import (
|
|
build_calendar_service,
|
|
build_oauth_service,
|
|
compute_free_time_intersection,
|
|
parse_datetime,
|
|
)
|
|
|
|
|
|
@tool(
|
|
requires_auth=Google(
|
|
scopes=[
|
|
"https://www.googleapis.com/auth/calendar.readonly",
|
|
"https://www.googleapis.com/auth/calendar.events",
|
|
]
|
|
)
|
|
)
|
|
async def list_calendars(
|
|
context: ToolContext,
|
|
max_results: Annotated[
|
|
int, "The maximum number of calendars to return. Up to 250 calendars, defaults to 10."
|
|
] = 10,
|
|
show_deleted: Annotated[bool, "Whether to show deleted calendars. Defaults to False"] = False,
|
|
show_hidden: Annotated[bool, "Whether to show hidden calendars. Defaults to False"] = False,
|
|
next_page_token: Annotated[
|
|
str | None, "The token to retrieve the next page of calendars. Optional."
|
|
] = None,
|
|
) -> Annotated[dict, "A dictionary containing the calendars accessible by the end user"]:
|
|
"""
|
|
List all calendars accessible by the user.
|
|
"""
|
|
max_results = max(1, min(max_results, 250))
|
|
service = build_calendar_service(context.get_auth_token_or_empty())
|
|
calendars = (
|
|
service.calendarList()
|
|
.list(
|
|
pageToken=next_page_token,
|
|
showDeleted=show_deleted,
|
|
showHidden=show_hidden,
|
|
maxResults=max_results,
|
|
)
|
|
.execute()
|
|
)
|
|
|
|
items = calendars.get("items", [])
|
|
keys = ["description", "id", "summary", "timeZone"]
|
|
relevant_items = [{k: i.get(k) for k in keys if i.get(k)} for i in items]
|
|
return {
|
|
"next_page_token": calendars.get("nextPageToken"),
|
|
"num_calendars": len(relevant_items),
|
|
"calendars": relevant_items,
|
|
}
|
|
|
|
|
|
@tool(
|
|
requires_auth=Google(
|
|
scopes=[
|
|
"https://www.googleapis.com/auth/calendar.readonly",
|
|
"https://www.googleapis.com/auth/calendar.events",
|
|
],
|
|
)
|
|
)
|
|
async def create_event(
|
|
context: ToolContext,
|
|
summary: Annotated[str, "The title of the event"],
|
|
start_datetime: Annotated[
|
|
str,
|
|
"The datetime when the event starts in ISO 8601 format, e.g., '2024-12-31T15:30:00'.",
|
|
],
|
|
end_datetime: Annotated[
|
|
str,
|
|
"The datetime when the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.",
|
|
],
|
|
calendar_id: Annotated[
|
|
str, "The ID of the calendar to create the event in, usually 'primary'."
|
|
] = "primary",
|
|
description: Annotated[str | None, "The description of the event"] = None,
|
|
location: Annotated[str | None, "The location of the event"] = None,
|
|
visibility: Annotated[EventVisibility, "The visibility of the event"] = EventVisibility.DEFAULT,
|
|
attendee_emails: Annotated[
|
|
list[str] | None,
|
|
"The list of attendee emails. Must be valid email addresses e.g., username@domain.com.",
|
|
] = None,
|
|
) -> Annotated[dict, "A dictionary containing the created event details"]:
|
|
"""Create a new event/meeting/sync/meetup in the specified calendar."""
|
|
|
|
service = build_calendar_service(context.get_auth_token_or_empty())
|
|
|
|
# Get the calendar's time zone
|
|
calendar = service.calendars().get(calendarId=calendar_id).execute()
|
|
time_zone = calendar["timeZone"]
|
|
|
|
# Parse datetime strings
|
|
start_dt = parse_datetime(start_datetime, time_zone)
|
|
end_dt = parse_datetime(end_datetime, time_zone)
|
|
|
|
event: dict[str, Any] = {
|
|
"summary": summary,
|
|
"description": description,
|
|
"location": location,
|
|
"start": {"dateTime": start_dt.isoformat(), "timeZone": time_zone},
|
|
"end": {"dateTime": end_dt.isoformat(), "timeZone": time_zone},
|
|
"visibility": visibility.value,
|
|
}
|
|
|
|
if attendee_emails:
|
|
event["attendees"] = [{"email": email} for email in attendee_emails]
|
|
|
|
created_event = service.events().insert(calendarId=calendar_id, body=event).execute()
|
|
return {"event": created_event}
|
|
|
|
|
|
@tool(
|
|
requires_auth=Google(
|
|
scopes=[
|
|
"https://www.googleapis.com/auth/calendar.readonly",
|
|
"https://www.googleapis.com/auth/calendar.events",
|
|
],
|
|
)
|
|
)
|
|
async def list_events(
|
|
context: ToolContext,
|
|
min_end_datetime: Annotated[
|
|
str,
|
|
"Filter by events that end on or after this datetime in ISO 8601 format, "
|
|
"e.g., '2024-09-15T09:00:00'.",
|
|
],
|
|
max_start_datetime: Annotated[
|
|
str,
|
|
"Filter by events that start before this datetime in ISO 8601 format, "
|
|
"e.g., '2024-09-16T17:00:00'.",
|
|
],
|
|
calendar_id: Annotated[str, "The ID of the calendar to list events from"] = "primary",
|
|
max_results: Annotated[int, "The maximum number of events to return"] = 10,
|
|
) -> Annotated[dict, "A dictionary containing the list of events"]:
|
|
"""
|
|
List events from the specified calendar within the given datetime range.
|
|
|
|
min_end_datetime serves as the lower bound (exclusive) for an event's end time.
|
|
max_start_datetime serves as the upper bound (exclusive) for an event's start time.
|
|
|
|
For example:
|
|
If min_end_datetime is set to 2024-09-15T09:00:00 and max_start_datetime
|
|
is set to 2024-09-16T17:00:00, the function will return events that:
|
|
1. End after 09:00 on September 15, 2024 (exclusive)
|
|
2. Start before 17:00 on September 16, 2024 (exclusive)
|
|
This means an event starting at 08:00 on September 15 and
|
|
ending at 10:00 on September 15 would be included, but an
|
|
event starting at 17:00 on September 16 would not be included.
|
|
"""
|
|
service = build_calendar_service(context.get_auth_token_or_empty())
|
|
|
|
# Get the calendar's time zone
|
|
calendar = service.calendars().get(calendarId=calendar_id).execute()
|
|
time_zone = calendar["timeZone"]
|
|
|
|
# Parse datetime strings
|
|
min_end_dt = parse_datetime(min_end_datetime, time_zone)
|
|
max_start_dt = parse_datetime(max_start_datetime, time_zone)
|
|
|
|
if min_end_dt > max_start_dt:
|
|
min_end_dt, max_start_dt = max_start_dt, min_end_dt
|
|
|
|
events_result = (
|
|
service.events()
|
|
.list(
|
|
calendarId=calendar_id,
|
|
timeMin=min_end_dt.isoformat(),
|
|
timeMax=max_start_dt.isoformat(),
|
|
maxResults=max_results,
|
|
singleEvents=True,
|
|
orderBy="startTime",
|
|
)
|
|
.execute()
|
|
)
|
|
|
|
items_keys = [
|
|
"attachments",
|
|
"attendees",
|
|
"creator",
|
|
"description",
|
|
"end",
|
|
"eventType",
|
|
"htmlLink",
|
|
"id",
|
|
"location",
|
|
"organizer",
|
|
"start",
|
|
"summary",
|
|
"visibility",
|
|
]
|
|
|
|
events = [
|
|
{key: event[key] for key in items_keys if key in event}
|
|
for event in events_result.get("items", [])
|
|
]
|
|
|
|
return {"events_count": len(events), "events": events}
|
|
|
|
|
|
@tool(
|
|
requires_auth=Google(
|
|
scopes=["https://www.googleapis.com/auth/calendar.events"],
|
|
)
|
|
)
|
|
async def update_event(
|
|
context: ToolContext,
|
|
event_id: Annotated[str, "The ID of the event to update"],
|
|
updated_start_datetime: Annotated[
|
|
str | None,
|
|
"The updated datetime that the event starts in ISO 8601 format, "
|
|
"e.g., '2024-12-31T15:30:00'.",
|
|
] = None,
|
|
updated_end_datetime: Annotated[
|
|
str | None,
|
|
"The updated datetime that the event ends in ISO 8601 format, e.g., '2024-12-31T17:30:00'.",
|
|
] = None,
|
|
updated_calendar_id: Annotated[
|
|
str | None, "The updated ID of the calendar containing the event."
|
|
] = None,
|
|
updated_summary: Annotated[str | None, "The updated title of the event"] = None,
|
|
updated_description: Annotated[str | None, "The updated description of the event"] = None,
|
|
updated_location: Annotated[str | None, "The updated location of the event"] = None,
|
|
updated_visibility: Annotated[EventVisibility | None, "The visibility of the event"] = None,
|
|
attendee_emails_to_add: Annotated[
|
|
list[str] | None,
|
|
"The list of attendee emails to add. Must be valid email addresses "
|
|
"e.g., username@domain.com.",
|
|
] = None,
|
|
attendee_emails_to_remove: Annotated[
|
|
list[str] | None,
|
|
"The list of attendee emails to remove. Must be valid email addresses "
|
|
"e.g., username@domain.com.",
|
|
] = None,
|
|
send_updates: Annotated[
|
|
SendUpdatesOptions,
|
|
"Should attendees be notified of the update? (none, all, external_only)",
|
|
] = SendUpdatesOptions.ALL,
|
|
) -> Annotated[
|
|
str,
|
|
"A string containing the updated event details, including the event ID, update timestamp, "
|
|
"and a link to view the updated event.",
|
|
]:
|
|
"""
|
|
Update an existing event in the specified calendar with the provided details.
|
|
Only the provided fields will be updated; others will remain unchanged.
|
|
|
|
`updated_start_datetime` and `updated_end_datetime` are
|
|
independent and can be provided separately.
|
|
"""
|
|
service = build_calendar_service(context.get_auth_token_or_empty())
|
|
|
|
calendar = service.calendars().get(calendarId="primary").execute()
|
|
time_zone = calendar["timeZone"]
|
|
|
|
try:
|
|
event = service.events().get(calendarId="primary", eventId=event_id).execute()
|
|
except HttpError:
|
|
valid_events_with_id = (
|
|
service.events()
|
|
.list(
|
|
calendarId="primary",
|
|
timeMin=(datetime.now() - timedelta(days=2)).isoformat(),
|
|
timeMax=(datetime.now() + timedelta(days=365)).isoformat(),
|
|
maxResults=50,
|
|
singleEvents=True,
|
|
orderBy="startTime",
|
|
)
|
|
.execute()
|
|
)
|
|
raise RetryableToolError(
|
|
f"Event with ID {event_id} not found.",
|
|
additional_prompt_content=(
|
|
f"Here is a list of valid events. The event_id parameter must match one of these: "
|
|
f"{valid_events_with_id}"
|
|
),
|
|
retry_after_ms=1000,
|
|
developer_message=(
|
|
f"Event with ID {event_id} not found. Please try again with a valid event ID."
|
|
),
|
|
)
|
|
|
|
update_fields = {
|
|
"start": {"dateTime": updated_start_datetime, "timeZone": time_zone}
|
|
if updated_start_datetime
|
|
else None,
|
|
"end": {"dateTime": updated_end_datetime, "timeZone": time_zone}
|
|
if updated_end_datetime
|
|
else None,
|
|
"calendarId": updated_calendar_id,
|
|
"sendUpdates": send_updates.value if send_updates else None,
|
|
"summary": updated_summary,
|
|
"description": updated_description,
|
|
"location": updated_location,
|
|
"visibility": updated_visibility.value if updated_visibility else None,
|
|
}
|
|
|
|
event.update({k: v for k, v in update_fields.items() if v is not None})
|
|
|
|
if attendee_emails_to_remove:
|
|
event["attendees"] = [
|
|
attendee
|
|
for attendee in event.get("attendees", [])
|
|
if attendee.get("email", "").lower()
|
|
not in [email.lower() for email in attendee_emails_to_remove]
|
|
]
|
|
|
|
if attendee_emails_to_add:
|
|
existing_emails = {
|
|
attendee.get("email", "").lower() for attendee in event.get("attendees", [])
|
|
}
|
|
new_attendees = [
|
|
{"email": email}
|
|
for email in attendee_emails_to_add
|
|
if email.lower() not in existing_emails
|
|
]
|
|
event["attendees"] = event.get("attendees", []) + new_attendees
|
|
|
|
updated_event = (
|
|
service.events()
|
|
.update(
|
|
calendarId="primary",
|
|
eventId=event_id,
|
|
sendUpdates=send_updates.value,
|
|
body=event,
|
|
)
|
|
.execute()
|
|
)
|
|
return (
|
|
f"Event with ID {event_id} successfully updated at {updated_event['updated']}. "
|
|
f"View updated event at {updated_event['htmlLink']}"
|
|
)
|
|
|
|
|
|
@tool(
|
|
requires_auth=Google(
|
|
scopes=["https://www.googleapis.com/auth/calendar.events"],
|
|
)
|
|
)
|
|
async def delete_event(
|
|
context: ToolContext,
|
|
event_id: Annotated[str, "The ID of the event to delete"],
|
|
calendar_id: Annotated[str, "The ID of the calendar containing the event"] = "primary",
|
|
send_updates: Annotated[
|
|
SendUpdatesOptions, "Specifies which attendees to notify about the deletion"
|
|
] = SendUpdatesOptions.ALL,
|
|
) -> Annotated[str, "A string containing the deletion confirmation message"]:
|
|
"""Delete an event from Google Calendar."""
|
|
service = build_calendar_service(context.get_auth_token_or_empty())
|
|
|
|
service.events().delete(
|
|
calendarId=calendar_id, eventId=event_id, sendUpdates=send_updates.value
|
|
).execute()
|
|
|
|
notification_message = ""
|
|
if send_updates == SendUpdatesOptions.ALL:
|
|
notification_message = "Notifications were sent to all attendees."
|
|
elif send_updates == SendUpdatesOptions.EXTERNAL_ONLY:
|
|
notification_message = "Notifications were sent to external attendees only."
|
|
elif send_updates == SendUpdatesOptions.NONE:
|
|
notification_message = "No notifications were sent to attendees."
|
|
|
|
return (
|
|
f"Event with ID '{event_id}' successfully deleted from calendar '{calendar_id}'. "
|
|
f"{notification_message}"
|
|
)
|
|
|
|
|
|
# TODO: would be nice to have a "min_slot_duration" parameter
|
|
# TODO: find a way to have "include_weekends" parameter without confusing LLMs
|
|
@tool(
|
|
requires_auth=Google(
|
|
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
|
|
),
|
|
)
|
|
async def find_time_slots_when_everyone_is_free(
|
|
context: ToolContext,
|
|
email_addresses: Annotated[
|
|
list[str] | None,
|
|
"The list of email addresses from people in the same organization domain (apart from the "
|
|
"currently logged in user) to search for free time slots. Defaults to None, which will "
|
|
"return free time slots for the current user only.",
|
|
] = None,
|
|
start_date: Annotated[
|
|
str | None,
|
|
"The start date to search for time slots in the format 'YYYY-MM-DD'. Defaults to today's "
|
|
"date. It will search starting from this date at the time 00:00:00.",
|
|
] = None,
|
|
end_date: Annotated[
|
|
str | None,
|
|
"The end date to search for time slots in the format 'YYYY-MM-DD'. Defaults to seven days "
|
|
"from the start date. It will search until this date at the time 23:59:59.",
|
|
] = None,
|
|
start_time_boundary: Annotated[
|
|
str,
|
|
"Will return free slots in any given day starting from this time in the format 'HH:MM'. "
|
|
"Defaults to '08:00', which is a usual business hour start time.",
|
|
] = "08:00",
|
|
end_time_boundary: Annotated[
|
|
str,
|
|
"Will return free slots in any given day until this time in the format 'HH:MM'. "
|
|
"Defaults to '18:00', which is a usual business hour end time.",
|
|
] = "18:00",
|
|
) -> Annotated[
|
|
dict,
|
|
"A dictionary with the free slots and the timezone in which time slots are represented.",
|
|
]:
|
|
"""
|
|
Provides time slots when everyone is free within a given date range and time boundaries.
|
|
"""
|
|
|
|
# Build google api services
|
|
oauth_service = build_oauth_service(context.get_auth_token_or_empty())
|
|
calendar_service = build_calendar_service(context.get_auth_token_or_empty())
|
|
|
|
email_addresses = email_addresses or []
|
|
|
|
if isinstance(email_addresses, str):
|
|
email_addresses = [email_addresses]
|
|
|
|
# Add the currently logged in user to the list of email addresses
|
|
user_info = oauth_service.userinfo().get().execute()
|
|
if user_info["email"] not in email_addresses:
|
|
email_addresses.append(user_info["email"])
|
|
|
|
# Get the timezone of the currently logged in user
|
|
calendar = calendar_service.calendars().get(calendarId="primary").execute()
|
|
timezone_name = calendar.get("timeZone")
|
|
|
|
try:
|
|
tz = ZoneInfo(timezone_name)
|
|
# If the calendar timezone name is not supported by Python's zoneinfo, use UTC
|
|
except ZoneInfoNotFoundError:
|
|
timezone_name = "UTC"
|
|
tz = ZoneInfo("UTC")
|
|
|
|
# Set default start and end dates, if not provided by the caller
|
|
start_date = start_date or datetime.now(tz=tz).date().isoformat()
|
|
end_date = end_date or (datetime.now(tz=tz).date() + timedelta(days=7)).isoformat()
|
|
|
|
# Parse start and end dates to datetime objects
|
|
start_datetime = datetime.strptime(start_date, "%Y-%m-%d").replace(
|
|
hour=0, minute=0, second=0, microsecond=0, tzinfo=tz
|
|
)
|
|
end_datetime = datetime.strptime(end_date, "%Y-%m-%d").replace(
|
|
hour=23, minute=59, second=59, microsecond=0, tzinfo=tz
|
|
)
|
|
|
|
# Get the busy slots from the calendars of the users
|
|
freebusy_response = (
|
|
calendar_service.freebusy()
|
|
.query(
|
|
body={
|
|
"timeMin": start_datetime.isoformat(),
|
|
"timeMax": end_datetime.isoformat(),
|
|
"timeZone": timezone_name,
|
|
"items": [{"id": email_address} for email_address in email_addresses],
|
|
}
|
|
)
|
|
.execute()
|
|
)
|
|
busy_slots = freebusy_response["calendars"]
|
|
|
|
response_errors = []
|
|
|
|
for email in email_addresses:
|
|
if "errors" not in busy_slots[email]:
|
|
continue
|
|
errors = busy_slots[email]["errors"]
|
|
for error in errors:
|
|
response_errors.append(
|
|
f"Error retrieving free slots from calendar of '{email}': "
|
|
f"{error.get('reason', 'not determined')}"
|
|
)
|
|
|
|
if response_errors:
|
|
raise RetryableToolError(
|
|
"Error retrieving free slots from calendars of one or more users.",
|
|
additional_prompt_content=json.dumps(response_errors),
|
|
retry_after_ms=1000,
|
|
developer_message="Error retrieving free slots from calendars of one or more users.",
|
|
)
|
|
|
|
# Compute the free slots
|
|
free_slots = compute_free_time_intersection(
|
|
busy_data=busy_slots,
|
|
global_start=start_datetime,
|
|
global_end=end_datetime,
|
|
start_time_boundary=datetime.strptime(start_time_boundary, "%H:%M")
|
|
.time()
|
|
.replace(tzinfo=tz),
|
|
end_time_boundary=datetime.strptime(end_time_boundary, "%H:%M").time().replace(tzinfo=tz),
|
|
include_weekends=True,
|
|
tz=tz,
|
|
)
|
|
|
|
return {
|
|
"free_slots": free_slots,
|
|
"timezone": timezone_name,
|
|
}
|