Add Outlook Calendar tools (#382)
This commit is contained in:
parent
c128717e53
commit
419a0ac717
18 changed files with 1592 additions and 4 deletions
|
|
@ -4,7 +4,7 @@ from typing import Any
|
||||||
from azure.core.credentials import AccessToken, TokenCredential
|
from azure.core.credentials import AccessToken, TokenCredential
|
||||||
from msgraph import GraphServiceClient
|
from msgraph import GraphServiceClient
|
||||||
|
|
||||||
from arcade_microsoft.outlook_mail.constants import DEFAULT_SCOPE
|
DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
|
||||||
|
|
||||||
|
|
||||||
class StaticTokenCredential(TokenCredential):
|
class StaticTokenCredential(TokenCredential):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
from arcade_microsoft.outlook_calendar.tools import (
|
||||||
|
create_event,
|
||||||
|
get_event,
|
||||||
|
list_events_in_time_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["create_event", "get_event", "list_events_in_time_range"]
|
||||||
225
toolkits/microsoft/arcade_microsoft/outlook_calendar/_utils.py
Normal file
225
toolkits/microsoft/arcade_microsoft/outlook_calendar/_utils.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from arcade.sdk.errors import ToolExecutionError
|
||||||
|
from kiota_abstractions.base_request_configuration import RequestConfiguration
|
||||||
|
from kiota_abstractions.headers_collection import HeadersCollection
|
||||||
|
from msgraph import GraphServiceClient
|
||||||
|
from msgraph.generated.users.item.mailbox_settings.mailbox_settings_request_builder import (
|
||||||
|
MailboxSettingsRequestBuilder,
|
||||||
|
)
|
||||||
|
|
||||||
|
from arcade_microsoft.outlook_calendar.constants import WINDOWS_TO_IANA
|
||||||
|
|
||||||
|
|
||||||
|
def validate_date_times(start_date_time: str, end_date_time: str) -> None:
|
||||||
|
"""
|
||||||
|
Validate date times are in ISO 8601 format and
|
||||||
|
that end time is after start time (ignoring timezone offsets).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date_time: The start date time string to validate.
|
||||||
|
end_date_time: The end date time string to validate.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the date times are not in ISO 8601 format
|
||||||
|
ToolExecutionError: If end time is not after start time.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This function ignores timezone offsets.
|
||||||
|
"""
|
||||||
|
# parse into offset-aware datetimes
|
||||||
|
start_aware = datetime.fromisoformat(start_date_time)
|
||||||
|
end_aware = datetime.fromisoformat(end_date_time)
|
||||||
|
|
||||||
|
# drop tzinfo to treat both as naïve local times
|
||||||
|
start_naive = start_aware.replace(tzinfo=None)
|
||||||
|
end_naive = end_aware.replace(tzinfo=None)
|
||||||
|
|
||||||
|
if start_naive >= end_naive:
|
||||||
|
raise ToolExecutionError(
|
||||||
|
message="Start time must be before end time",
|
||||||
|
developer_message=(
|
||||||
|
f"The start time '{start_naive}' is not before the end time '{end_naive}'"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_meeting_body(
|
||||||
|
body: str, custom_meeting_url: str | None, is_online_meeting: bool
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
"""Prepare meeting body and determine final online meeting status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: The original meeting body text
|
||||||
|
custom_meeting_url: Custom URL for the meeting, if one exists
|
||||||
|
is_online_meeting: Whether this should be an online meeting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (Updated meeting body, final online meeting status)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
If a custom meeting URL is provided, is_online_meeting will be set to False
|
||||||
|
to prevent Microsoft from generating its own meeting URL. The custom meeting
|
||||||
|
URL will then be added to the body of the meeting.
|
||||||
|
"""
|
||||||
|
is_online_meeting = not custom_meeting_url and is_online_meeting
|
||||||
|
|
||||||
|
if custom_meeting_url:
|
||||||
|
body = f"""{body}\n
|
||||||
|
.........................................................................
|
||||||
|
Join online meeting
|
||||||
|
{custom_meeting_url}"""
|
||||||
|
|
||||||
|
return body, is_online_meeting
|
||||||
|
|
||||||
|
|
||||||
|
def validate_emails(emails: list[str]) -> None:
|
||||||
|
"""Validate a list of email addresses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emails: The list of email addresses to validate.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ToolExecutionError: If any email address is invalid.
|
||||||
|
"""
|
||||||
|
invalid_emails = []
|
||||||
|
for email in emails:
|
||||||
|
if not is_valid_email(email):
|
||||||
|
invalid_emails.append(email)
|
||||||
|
if invalid_emails:
|
||||||
|
raise ToolExecutionError(message=f"Invalid email address(es): {', '.join(invalid_emails)}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_email(email: str) -> bool:
|
||||||
|
"""Simple check to see if an email address is valid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: The email address to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the email address is valid, False otherwise.
|
||||||
|
"""
|
||||||
|
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||||
|
return re.match(pattern, email) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def remove_timezone_offset(date_time: str) -> str:
|
||||||
|
"""Remove the timezone offset from the date_time string."""
|
||||||
|
return re.sub(r"[+-][0-9]{2}:[0-9]{2}$|Z$", "", date_time)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_timezone_offset(date_time: str, time_zone_offset: str) -> str:
|
||||||
|
"""Replace the timezone offset in the date_time string with the time_zone_offset.
|
||||||
|
|
||||||
|
If the date_time str already contains a timezone offset, it will be replaced.
|
||||||
|
If the date_time str does not contain a timezone offset, the time_zone_offset will be appended
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_time: The date_time string to replace the timezone offset in.
|
||||||
|
time_zone_offset: The timezone offset to replace the existing timezone offset with.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The date_time string with the timezone offset replaced or appended.
|
||||||
|
"""
|
||||||
|
date_time = remove_timezone_offset(date_time)
|
||||||
|
return f"{date_time}{time_zone_offset}"
|
||||||
|
|
||||||
|
|
||||||
|
def convert_timezone_to_offset(time_zone: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert a timezone (Windows or IANA) to ISO 8601 offset.
|
||||||
|
First tries Windows timezone format, then IANA, then falls back to UTC if both fail.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_zone: The timezone (Windows or IANA) to convert to ISO 8601 offset.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The timezone offset in ISO 8601 format (e.g. '+08:00', '-07:00', or 'Z' for UTC)
|
||||||
|
"""
|
||||||
|
# Try Windows timezone format
|
||||||
|
iana_timezone = WINDOWS_TO_IANA.get(time_zone)
|
||||||
|
if iana_timezone:
|
||||||
|
try:
|
||||||
|
tz = pytz.timezone(iana_timezone)
|
||||||
|
now = datetime.now(tz)
|
||||||
|
tz_offset = now.strftime("%z")
|
||||||
|
|
||||||
|
if len(tz_offset) == 5: # +HHMM format
|
||||||
|
tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" # +HH:MM format
|
||||||
|
return tz_offset # noqa: TRY300
|
||||||
|
except (pytz.exceptions.UnknownTimeZoneError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try IANA timezone format
|
||||||
|
try:
|
||||||
|
tz = pytz.timezone(time_zone)
|
||||||
|
now = datetime.now(tz)
|
||||||
|
tz_offset = now.strftime("%z")
|
||||||
|
|
||||||
|
if len(tz_offset) == 5: # +HHMM format
|
||||||
|
tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" # +HH:MM format
|
||||||
|
return tz_offset # noqa: TRY300
|
||||||
|
except (pytz.exceptions.UnknownTimeZoneError, ValueError):
|
||||||
|
# Fallback to UTC
|
||||||
|
return "Z"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_default_calendar_timezone(client: GraphServiceClient) -> str:
|
||||||
|
"""Get the authenticated user's default calendar's timezone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: The GraphServiceClient to use to get
|
||||||
|
the authenticated user's default calendar's timezone.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The timezone in "Windows timezone format" or "IANA timezone format".
|
||||||
|
"""
|
||||||
|
query_params = MailboxSettingsRequestBuilder.MailboxSettingsRequestBuilderGetQueryParameters(
|
||||||
|
select=["timeZone"]
|
||||||
|
)
|
||||||
|
request_config = RequestConfiguration(
|
||||||
|
query_parameters=query_params,
|
||||||
|
)
|
||||||
|
response = await client.me.mailbox_settings.get(request_config)
|
||||||
|
|
||||||
|
if response and response.time_zone:
|
||||||
|
return response.time_zone
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
|
||||||
|
def create_timezone_headers(time_zone: str) -> HeadersCollection:
|
||||||
|
"""
|
||||||
|
Create headers with timezone preference.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_zone: The timezone to set in the headers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Headers collection with timezone preference set.
|
||||||
|
"""
|
||||||
|
headers = HeadersCollection()
|
||||||
|
headers.try_add("Prefer", f'outlook.timezone="{time_zone}"')
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def create_timezone_request_config(
|
||||||
|
time_zone: str, query_parameters: Any | None = None
|
||||||
|
) -> RequestConfiguration:
|
||||||
|
"""
|
||||||
|
Create a request configuration with timezone headers and optional query parameters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_zone: The timezone to set in the headers.
|
||||||
|
query_parameters: Optional query parameters to include in the configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Request configuration with timezone headers and optional query parameters.
|
||||||
|
"""
|
||||||
|
headers = create_timezone_headers(time_zone)
|
||||||
|
return RequestConfiguration(
|
||||||
|
headers=headers,
|
||||||
|
query_parameters=query_parameters,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Maps "Windows timezone format" to "IANA timezone format"
|
||||||
|
# Does not include all Windows timezones.
|
||||||
|
WINDOWS_TO_IANA = {
|
||||||
|
"Dateline Standard Time": "Etc/GMT+12",
|
||||||
|
"UTC-11": "Etc/GMT+11",
|
||||||
|
"Aleutian Standard Time": "America/Adak",
|
||||||
|
"Hawaiian Standard Time": "Pacific/Honolulu",
|
||||||
|
"Marquesas Standard Time": "Pacific/Marquesas",
|
||||||
|
"Alaskan Standard Time": "America/Anchorage",
|
||||||
|
"UTC-09": "Etc/GMT+9",
|
||||||
|
"Pacific Standard Time (Mexico)": "America/Tijuana",
|
||||||
|
"UTC-08": "Etc/GMT+8",
|
||||||
|
"Pacific Standard Time": "America/Los_Angeles",
|
||||||
|
"US Mountain Standard Time": "America/Phoenix",
|
||||||
|
"Mountain Standard Time (Mexico)": "America/Chihuahua",
|
||||||
|
"Mountain Standard Time": "America/Denver",
|
||||||
|
"Central America Standard Time": "America/Guatemala",
|
||||||
|
"Central Standard Time": "America/Chicago",
|
||||||
|
"Easter Island Standard Time": "Pacific/Easter",
|
||||||
|
"Central Standard Time (Mexico)": "America/Mexico_City",
|
||||||
|
"Canada Central Standard Time": "America/Regina",
|
||||||
|
"SA Pacific Standard Time": "America/Bogota",
|
||||||
|
"Eastern Standard Time (Mexico)": "America/Cancun",
|
||||||
|
"Eastern Standard Time": "America/New_York",
|
||||||
|
"Haiti Standard Time": "America/Port-au-Prince",
|
||||||
|
"Cuba Standard Time": "America/Havana",
|
||||||
|
"US Eastern Standard Time": "America/Indianapolis",
|
||||||
|
"Turks And Caicos Standard Time": "America/Grand_Turk",
|
||||||
|
"Paraguay Standard Time": "America/Asuncion",
|
||||||
|
"Atlantic Standard Time": "America/Halifax",
|
||||||
|
"Venezuela Standard Time": "America/Caracas",
|
||||||
|
"Central Brazilian Standard Time": "America/Cuiaba",
|
||||||
|
"SA Western Standard Time": "America/La_Paz",
|
||||||
|
"Pacific SA Standard Time": "America/Santiago",
|
||||||
|
"Newfoundland Standard Time": "America/St_Johns",
|
||||||
|
"Tocantins Standard Time": "America/Araguaina",
|
||||||
|
"E. South America Standard Time": "America/Sao_Paulo",
|
||||||
|
"SA Eastern Standard Time": "America/Cayenne",
|
||||||
|
"Argentina Standard Time": "America/Buenos_Aires",
|
||||||
|
"Greenland Standard Time": "America/Godthab",
|
||||||
|
"Montevideo Standard Time": "America/Montevideo",
|
||||||
|
"Magallanes Standard Time": "America/Punta_Arenas",
|
||||||
|
"Saint Pierre Standard Time": "America/Miquelon",
|
||||||
|
"Bahia Standard Time": "America/Bahia",
|
||||||
|
"UTC-02": "Etc/GMT+2",
|
||||||
|
"Azores Standard Time": "Atlantic/Azores",
|
||||||
|
"Cape Verde Standard Time": "Atlantic/Cape_Verde",
|
||||||
|
"UTC": "Etc/UTC",
|
||||||
|
"GMT Standard Time": "Europe/London",
|
||||||
|
"Greenwich Standard Time": "Atlantic/Reykjavik",
|
||||||
|
"W. Europe Standard Time": "Europe/Berlin",
|
||||||
|
"Central Europe Standard Time": "Europe/Budapest",
|
||||||
|
"Romance Standard Time": "Europe/Paris",
|
||||||
|
"Central European Standard Time": "Europe/Warsaw",
|
||||||
|
"W. Central Africa Standard Time": "Africa/Lagos",
|
||||||
|
"Jordan Standard Time": "Asia/Amman",
|
||||||
|
"GTB Standard Time": "Europe/Bucharest",
|
||||||
|
"Middle East Standard Time": "Asia/Beirut",
|
||||||
|
"Egypt Standard Time": "Africa/Cairo",
|
||||||
|
"E. Europe Standard Time": "Europe/Chisinau",
|
||||||
|
"Syria Standard Time": "Asia/Damascus",
|
||||||
|
"West Bank Standard Time": "Asia/Hebron",
|
||||||
|
"South Africa Standard Time": "Africa/Johannesburg",
|
||||||
|
"FLE Standard Time": "Europe/Kiev",
|
||||||
|
"Israel Standard Time": "Asia/Jerusalem",
|
||||||
|
"Kaliningrad Standard Time": "Europe/Kaliningrad",
|
||||||
|
"Sudan Standard Time": "Africa/Khartoum",
|
||||||
|
"Libya Standard Time": "Africa/Tripoli",
|
||||||
|
"Namibia Standard Time": "Africa/Windhoek",
|
||||||
|
"Arabic Standard Time": "Asia/Baghdad",
|
||||||
|
"Turkey Standard Time": "Europe/Istanbul",
|
||||||
|
"Arab Standard Time": "Asia/Riyadh",
|
||||||
|
"Belarus Standard Time": "Europe/Minsk",
|
||||||
|
"Russian Standard Time": "Europe/Moscow",
|
||||||
|
"E. Africa Standard Time": "Africa/Nairobi",
|
||||||
|
"Iran Standard Time": "Asia/Tehran",
|
||||||
|
"Arabian Standard Time": "Asia/Dubai",
|
||||||
|
"Astrakhan Standard Time": "Europe/Astrakhan",
|
||||||
|
"Azerbaijan Standard Time": "Asia/Baku",
|
||||||
|
"Russia Time Zone 3": "Europe/Samara",
|
||||||
|
"Mauritius Standard Time": "Indian/Mauritius",
|
||||||
|
"Saratov Standard Time": "Europe/Saratov",
|
||||||
|
"Georgian Standard Time": "Asia/Tbilisi",
|
||||||
|
"Volgograd Standard Time": "Europe/Volgograd",
|
||||||
|
"Caucasus Standard Time": "Asia/Yerevan",
|
||||||
|
"Afghanistan Standard Time": "Asia/Kabul",
|
||||||
|
"West Asia Standard Time": "Asia/Tashkent",
|
||||||
|
"Ekaterinburg Standard Time": "Asia/Yekaterinburg",
|
||||||
|
"Pakistan Standard Time": "Asia/Karachi",
|
||||||
|
"India Standard Time": "Asia/Calcutta",
|
||||||
|
"Sri Lanka Standard Time": "Asia/Colombo",
|
||||||
|
"Nepal Standard Time": "Asia/Kathmandu",
|
||||||
|
"Central Asia Standard Time": "Asia/Almaty",
|
||||||
|
"Bangladesh Standard Time": "Asia/Dhaka",
|
||||||
|
"Omsk Standard Time": "Asia/Omsk",
|
||||||
|
"Myanmar Standard Time": "Asia/Rangoon",
|
||||||
|
"SE Asia Standard Time": "Asia/Bangkok",
|
||||||
|
"Altai Standard Time": "Asia/Barnaul",
|
||||||
|
"W. Mongolia Standard Time": "Asia/Hovd",
|
||||||
|
"North Asia Standard Time": "Asia/Krasnoyarsk",
|
||||||
|
"N. Central Asia Standard Time": "Asia/Novosibirsk",
|
||||||
|
"Tomsk Standard Time": "Asia/Tomsk",
|
||||||
|
"China Standard Time": "Asia/Shanghai",
|
||||||
|
"North Asia East Standard Time": "Asia/Irkutsk",
|
||||||
|
"Singapore Standard Time": "Asia/Singapore",
|
||||||
|
"W. Australia Standard Time": "Australia/Perth",
|
||||||
|
"Taipei Standard Time": "Asia/Taipei",
|
||||||
|
"Ulaanbaatar Standard Time": "Asia/Ulaanbaatar",
|
||||||
|
"North Korea Standard Time": "Asia/Pyongyang",
|
||||||
|
"Aus Central W. Standard Time": "Australia/Eucla",
|
||||||
|
"Transbaikal Standard Time": "Asia/Chita",
|
||||||
|
"Tokyo Standard Time": "Asia/Tokyo",
|
||||||
|
"Korea Standard Time": "Asia/Seoul",
|
||||||
|
"Yakutsk Standard Time": "Asia/Yakutsk",
|
||||||
|
"Cen. Australia Standard Time": "Australia/Adelaide",
|
||||||
|
"AUS Central Standard Time": "Australia/Darwin",
|
||||||
|
"E. Australia Standard Time": "Australia/Brisbane",
|
||||||
|
"AUS Eastern Standard Time": "Australia/Sydney",
|
||||||
|
"West Pacific Standard Time": "Pacific/Port_Moresby",
|
||||||
|
"Tasmania Standard Time": "Australia/Hobart",
|
||||||
|
"Vladivostok Standard Time": "Asia/Vladivostok",
|
||||||
|
"Lord Howe Standard Time": "Australia/Lord_Howe",
|
||||||
|
"Bougainville Standard Time": "Pacific/Bougainville",
|
||||||
|
"Russia Time Zone 10": "Asia/Srednekolymsk",
|
||||||
|
"Magadan Standard Time": "Asia/Magadan",
|
||||||
|
"Norfolk Standard Time": "Pacific/Norfolk",
|
||||||
|
"Sakhalin Standard Time": "Asia/Sakhalin",
|
||||||
|
"Central Pacific Standard Time": "Pacific/Guadalcanal",
|
||||||
|
"Russia Time Zone 11": "Asia/Kamchatka",
|
||||||
|
"New Zealand Standard Time": "Pacific/Auckland",
|
||||||
|
"UTC+12": "Etc/GMT-12",
|
||||||
|
"Fiji Standard Time": "Pacific/Fiji",
|
||||||
|
"Chatham Islands Standard Time": "Pacific/Chatham",
|
||||||
|
"UTC+13": "Etc/GMT-13",
|
||||||
|
"Tonga Standard Time": "Pacific/Tongatapu",
|
||||||
|
"Samoa Standard Time": "Pacific/Apia",
|
||||||
|
"Line Islands Standard Time": "Pacific/Kiritimati",
|
||||||
|
}
|
||||||
288
toolkits/microsoft/arcade_microsoft/outlook_calendar/models.py
Normal file
288
toolkits/microsoft/arcade_microsoft/outlook_calendar/models.py
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from msgraph.generated.models.attendee import Attendee as GraphAttendee
|
||||||
|
from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone as GraphDateTimeTimeZone
|
||||||
|
from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress
|
||||||
|
from msgraph.generated.models.event import Event as GraphEvent
|
||||||
|
from msgraph.generated.models.event_type import EventType as GraphEventType
|
||||||
|
from msgraph.generated.models.free_busy_status import FreeBusyStatus as GraphFreeBusyStatus
|
||||||
|
from msgraph.generated.models.importance import Importance as GraphImportance
|
||||||
|
from msgraph.generated.models.item_body import ItemBody as GraphItemBody
|
||||||
|
from msgraph.generated.models.location import Location as GraphLocation
|
||||||
|
from msgraph.generated.models.recipient import Recipient as GraphRecipient
|
||||||
|
from msgraph.generated.models.response_status import ResponseStatus as GraphResponseStatus
|
||||||
|
from msgraph.generated.models.response_type import ResponseType as GraphResponseType
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Attendee:
|
||||||
|
"""An attendee of a calendar event."""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
address: str = ""
|
||||||
|
response: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_sdk(cls, attendee: GraphAttendee) -> "Attendee":
|
||||||
|
"""Convert a Microsoft Graph SDK Attendee object to an Attendee dataclass."""
|
||||||
|
return cls(
|
||||||
|
name=attendee.email_address.name
|
||||||
|
if attendee.email_address and attendee.email_address.name
|
||||||
|
else "",
|
||||||
|
address=attendee.email_address.address
|
||||||
|
if attendee.email_address and attendee.email_address.address
|
||||||
|
else "",
|
||||||
|
response=attendee.status.response
|
||||||
|
if attendee.status and attendee.status.response
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"address": self.address,
|
||||||
|
"response": self.response,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_sdk(self) -> GraphAttendee:
|
||||||
|
"""Convert an Attendee dataclass to a Microsoft Graph SDK Attendee object."""
|
||||||
|
return GraphAttendee(
|
||||||
|
email_address=GraphEmailAddress(name=self.name, address=self.address),
|
||||||
|
status=GraphResponseStatus(
|
||||||
|
response=GraphResponseType(self.response)
|
||||||
|
if self.response
|
||||||
|
else GraphResponseType.None_
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Organizer:
|
||||||
|
"""The organizer of an event."""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
address: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_sdk(cls, organizer: GraphRecipient) -> "Organizer":
|
||||||
|
"""Convert a Microsoft Graph SDK Organizer object to an Organizer dataclass."""
|
||||||
|
return cls(
|
||||||
|
name=organizer.email_address.name
|
||||||
|
if organizer.email_address and organizer.email_address.name
|
||||||
|
else "",
|
||||||
|
address=organizer.email_address.address
|
||||||
|
if organizer.email_address and organizer.email_address.address
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"address": self.address,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_sdk(self) -> GraphRecipient:
|
||||||
|
"""Convert an Organizer dataclass to a Microsoft Graph SDK Organizer object."""
|
||||||
|
recipient = GraphRecipient(
|
||||||
|
email_address=GraphEmailAddress(name=self.name, address=self.address)
|
||||||
|
)
|
||||||
|
return recipient
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DateTimeTimeZone:
|
||||||
|
"""Time information for an event."""
|
||||||
|
|
||||||
|
date_time: str = ""
|
||||||
|
time_zone: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_sdk(cls, date_time_time_zone: GraphDateTimeTimeZone) -> "DateTimeTimeZone":
|
||||||
|
"""Convert a Microsoft Graph SDK DateTimeTimeZone object to a TimeInfo dataclass."""
|
||||||
|
return cls(
|
||||||
|
date_time=date_time_time_zone.date_time or "",
|
||||||
|
time_zone=date_time_time_zone.time_zone or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"dateTime": self.date_time,
|
||||||
|
"timeZone": self.time_zone,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_sdk(self) -> GraphDateTimeTimeZone:
|
||||||
|
"""Convert a TimeInfo dataclass to a Microsoft Graph SDK DateTimeTimeZone object."""
|
||||||
|
return GraphDateTimeTimeZone(date_time=self.date_time, time_zone=self.time_zone)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResponseStatus:
|
||||||
|
"""The response status for an event."""
|
||||||
|
|
||||||
|
response: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_sdk(cls, response_status: GraphResponseStatus) -> "ResponseStatus":
|
||||||
|
"""Convert a Microsoft Graph SDK ResponseStatus object to a ResponseStatus dataclass."""
|
||||||
|
response_value = (
|
||||||
|
str(response_status.response.value)
|
||||||
|
if response_status.response and hasattr(response_status.response, "value")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return cls(response=response_value)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"response": self.response,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_sdk(self) -> GraphResponseStatus:
|
||||||
|
"""Convert a ResponseStatus dataclass to a Microsoft Graph SDK ResponseStatus object."""
|
||||||
|
return GraphResponseStatus(response=GraphResponseType(self.response))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Event:
|
||||||
|
"""A calendar event in Outlook."""
|
||||||
|
|
||||||
|
attendees: list[Attendee] = field(default_factory=list)
|
||||||
|
body: str = ""
|
||||||
|
end: DateTimeTimeZone | None = None
|
||||||
|
has_attachments: bool = False
|
||||||
|
importance: str = ""
|
||||||
|
is_all_day: bool = False
|
||||||
|
is_cancelled: bool = False
|
||||||
|
is_draft: bool = False
|
||||||
|
is_online_meeting: bool = False
|
||||||
|
is_organizer: bool = False
|
||||||
|
location: str = ""
|
||||||
|
online_meeting_url: str = ""
|
||||||
|
organizer: Organizer | None = None
|
||||||
|
id: str = ""
|
||||||
|
response_status: ResponseStatus | None = None
|
||||||
|
show_as: str = ""
|
||||||
|
start: DateTimeTimeZone | None = None
|
||||||
|
subject: str = ""
|
||||||
|
type: str = ""
|
||||||
|
web_link: str = ""
|
||||||
|
event_id: str = "" # The unique identifier of the event. Read-only.
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_str(value: Any) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, bytes | bytearray):
|
||||||
|
return value.decode("utf-8", errors="ignore")
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_bool(value: Any) -> bool:
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_body(mime: str) -> str:
|
||||||
|
if not mime:
|
||||||
|
return ""
|
||||||
|
soup = BeautifulSoup(mime, "html.parser")
|
||||||
|
text = soup.get_text(separator=" ")
|
||||||
|
# Replace multiple newlines with a single newline
|
||||||
|
text = re.sub(r"\n+", "\n", text)
|
||||||
|
# Replace multiple spaces with a single space
|
||||||
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
# Replace sequences of dots (likely from horizontal lines) with a single newline
|
||||||
|
text = re.sub(r"\.{3,}", "\n---\n", text)
|
||||||
|
# Remove leading/trailing whitespace from each line
|
||||||
|
text = "\n".join(line.strip() for line in text.split("\n"))
|
||||||
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_sdk(cls, event: GraphEvent) -> "Event":
|
||||||
|
"""Convert a Microsoft Graph SDK Event object to an Event dataclass."""
|
||||||
|
body_mime = event.body.content if event.body and event.body.content else ""
|
||||||
|
body = cls._parse_body(body_mime)
|
||||||
|
|
||||||
|
attendees = [Attendee.from_sdk(a) for a in event.attendees if a] if event.attendees else []
|
||||||
|
start = DateTimeTimeZone.from_sdk(event.start) if event.start else None
|
||||||
|
end = DateTimeTimeZone.from_sdk(event.end) if event.end else None
|
||||||
|
organizer = Organizer.from_sdk(event.organizer) if event.organizer else None
|
||||||
|
response_status = (
|
||||||
|
ResponseStatus.from_sdk(event.response_status) if event.response_status else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
attendees=attendees,
|
||||||
|
body=body,
|
||||||
|
end=end,
|
||||||
|
has_attachments=cls._safe_bool(event.has_attachments),
|
||||||
|
importance=cls._safe_str(str(event.importance.value)) if event.importance else "",
|
||||||
|
is_all_day=cls._safe_bool(event.is_all_day),
|
||||||
|
is_cancelled=cls._safe_bool(event.is_cancelled),
|
||||||
|
is_draft=cls._safe_bool(event.is_draft),
|
||||||
|
is_online_meeting=cls._safe_bool(event.is_online_meeting),
|
||||||
|
is_organizer=cls._safe_bool(event.is_organizer),
|
||||||
|
location=cls._safe_str(event.location.display_name if event.location else ""),
|
||||||
|
online_meeting_url=cls._safe_str(event.online_meeting_url),
|
||||||
|
organizer=organizer,
|
||||||
|
id=cls._safe_str(event.id),
|
||||||
|
response_status=response_status,
|
||||||
|
show_as=cls._safe_str(str(event.show_as.value)) if event.show_as else "",
|
||||||
|
start=start,
|
||||||
|
subject=cls._safe_str(event.subject),
|
||||||
|
type=cls._safe_str(str(event.type.value)) if event.type else "",
|
||||||
|
web_link=cls._safe_str(event.web_link),
|
||||||
|
event_id=cls._safe_str(event.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Converts the Event dataclass to a dictionary."""
|
||||||
|
return {
|
||||||
|
"attendees": [attendee.to_dict() for attendee in self.attendees],
|
||||||
|
"body": self.body,
|
||||||
|
"end": self.end.to_dict() if self.end else None,
|
||||||
|
"has_attachments": self.has_attachments,
|
||||||
|
"importance": self.importance,
|
||||||
|
"is_all_day": self.is_all_day,
|
||||||
|
"is_cancelled": self.is_cancelled,
|
||||||
|
"is_draft": self.is_draft,
|
||||||
|
"is_online_meeting": self.is_online_meeting,
|
||||||
|
"is_organizer": self.is_organizer,
|
||||||
|
"location": self.location,
|
||||||
|
"online_meeting_url": self.online_meeting_url,
|
||||||
|
"organizer": self.organizer.to_dict() if self.organizer else None,
|
||||||
|
"id": self.id,
|
||||||
|
"response_status": self.response_status.to_dict() if self.response_status else None,
|
||||||
|
"show_as": self.show_as,
|
||||||
|
"start": self.start.to_dict() if self.start else None,
|
||||||
|
"subject": self.subject,
|
||||||
|
"type": self.type,
|
||||||
|
"web_link": self.web_link,
|
||||||
|
"event_id": self.event_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_sdk(self) -> GraphEvent:
|
||||||
|
"""Convert an Event dataclass to a Microsoft Graph SDK Event object."""
|
||||||
|
return GraphEvent(
|
||||||
|
attendees=[attendee.to_sdk() for attendee in self.attendees],
|
||||||
|
body=GraphItemBody(content=self.body),
|
||||||
|
end=self.end.to_sdk() if self.end else None,
|
||||||
|
has_attachments=self.has_attachments,
|
||||||
|
importance=GraphImportance(self.importance) if self.importance else None,
|
||||||
|
is_all_day=self.is_all_day,
|
||||||
|
is_cancelled=self.is_cancelled,
|
||||||
|
is_draft=self.is_draft,
|
||||||
|
is_online_meeting=self.is_online_meeting,
|
||||||
|
is_organizer=self.is_organizer,
|
||||||
|
location=GraphLocation(display_name=self.location),
|
||||||
|
online_meeting_url=self.online_meeting_url,
|
||||||
|
organizer=self.organizer.to_sdk() if self.organizer else None,
|
||||||
|
id=self.id,
|
||||||
|
response_status=self.response_status.to_sdk() if self.response_status else None,
|
||||||
|
show_as=GraphFreeBusyStatus(self.show_as) if self.show_as else None,
|
||||||
|
start=self.start.to_sdk() if self.start else None,
|
||||||
|
subject=self.subject,
|
||||||
|
type=GraphEventType(self.type) if self.type else None,
|
||||||
|
web_link=self.web_link,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
from arcade_microsoft.outlook_calendar.tools.create_event import create_event
|
||||||
|
from arcade_microsoft.outlook_calendar.tools.get_event import get_event
|
||||||
|
from arcade_microsoft.outlook_calendar.tools.list_events_in_time_range import (
|
||||||
|
list_events_in_time_range,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["create_event", "get_event", "list_events_in_time_range"]
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from arcade.sdk import ToolContext, tool
|
||||||
|
from arcade.sdk.auth import Microsoft
|
||||||
|
|
||||||
|
from arcade_microsoft.client import get_client
|
||||||
|
from arcade_microsoft.outlook_calendar._utils import (
|
||||||
|
create_timezone_request_config,
|
||||||
|
get_default_calendar_timezone,
|
||||||
|
prepare_meeting_body,
|
||||||
|
remove_timezone_offset,
|
||||||
|
validate_date_times,
|
||||||
|
validate_emails,
|
||||||
|
)
|
||||||
|
from arcade_microsoft.outlook_calendar.models import (
|
||||||
|
Attendee,
|
||||||
|
DateTimeTimeZone,
|
||||||
|
Event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadWrite"]))
|
||||||
|
async def create_event(
|
||||||
|
context: ToolContext,
|
||||||
|
subject: Annotated[str, "The text of the event's subject (title) line."],
|
||||||
|
body: Annotated[str, "The body of the event"],
|
||||||
|
start_date_time: Annotated[
|
||||||
|
str,
|
||||||
|
"The datetime of the event's start, represented in "
|
||||||
|
"ISO 8601 format. Timezone offset is ignored. For example, 2025-04-25T13:00:00",
|
||||||
|
],
|
||||||
|
end_date_time: Annotated[
|
||||||
|
str,
|
||||||
|
"The datetime of the event's end, represented in "
|
||||||
|
"ISO 8601 format. Timezone offset is ignored. For example, 2025-04-25T13:30:00",
|
||||||
|
],
|
||||||
|
location: Annotated[str | None, "The location of the event"] = None,
|
||||||
|
attendee_emails: Annotated[
|
||||||
|
list[str] | None,
|
||||||
|
"The email addresses of the attendees of the event. "
|
||||||
|
"Must be valid email addresses e.g., username@domain.com.",
|
||||||
|
] = None,
|
||||||
|
is_online_meeting: Annotated[
|
||||||
|
bool, "Whether the event is an online meeting. Defaults to False"
|
||||||
|
] = False,
|
||||||
|
custom_meeting_url: Annotated[
|
||||||
|
str | None,
|
||||||
|
"The URL of the online meeting. If not provided and is_online_meeting is True, "
|
||||||
|
"then a url will be generated for you",
|
||||||
|
] = None,
|
||||||
|
) -> Annotated[dict, "A dictionary containing the created event details"]:
|
||||||
|
"""Create an event in the authenticated user's default calendar.
|
||||||
|
|
||||||
|
Ignores timezone offsets provided in the start_date_time and end_date_time parameters.
|
||||||
|
Instead, uses the user's default calendar timezone to filter events.
|
||||||
|
If the user has not set a timezone for their calendar, then the timezone will be UTC.
|
||||||
|
"""
|
||||||
|
# Validate & cleanse inputs
|
||||||
|
validate_emails(attendee_emails or [])
|
||||||
|
validate_date_times(start_date_time, end_date_time)
|
||||||
|
body, is_online_meeting = prepare_meeting_body(body, custom_meeting_url, is_online_meeting)
|
||||||
|
|
||||||
|
client = get_client(context.get_auth_token_or_empty())
|
||||||
|
|
||||||
|
time_zone = await get_default_calendar_timezone(client)
|
||||||
|
start_date_time = remove_timezone_offset(start_date_time)
|
||||||
|
end_date_time = remove_timezone_offset(end_date_time)
|
||||||
|
event = Event(
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
start=DateTimeTimeZone(date_time=start_date_time, time_zone=time_zone),
|
||||||
|
end=DateTimeTimeZone(date_time=end_date_time, time_zone=time_zone),
|
||||||
|
location=location or "",
|
||||||
|
attendees=[Attendee(address=attendee) for attendee in attendee_emails or []],
|
||||||
|
is_online_meeting=is_online_meeting,
|
||||||
|
).to_sdk()
|
||||||
|
request_config = create_timezone_request_config(time_zone)
|
||||||
|
|
||||||
|
response = await client.me.events.post(body=event, request_configuration=request_config)
|
||||||
|
|
||||||
|
return Event.from_sdk(response).to_dict() # type: ignore[arg-type]
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from arcade.sdk import ToolContext, tool
|
||||||
|
from arcade.sdk.auth import Microsoft
|
||||||
|
|
||||||
|
from arcade_microsoft.client import get_client
|
||||||
|
from arcade_microsoft.outlook_calendar._utils import (
|
||||||
|
create_timezone_request_config,
|
||||||
|
get_default_calendar_timezone,
|
||||||
|
)
|
||||||
|
from arcade_microsoft.outlook_calendar.models import Event
|
||||||
|
|
||||||
|
|
||||||
|
@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadBasic"]))
|
||||||
|
async def get_event(
|
||||||
|
context: ToolContext,
|
||||||
|
event_id: Annotated[str, "The ID of the event to get"],
|
||||||
|
) -> Annotated[dict, "A dictionary containing the event details"]:
|
||||||
|
"""Get an event by its ID from the user's calendar."""
|
||||||
|
client = get_client(context.get_auth_token_or_empty())
|
||||||
|
|
||||||
|
time_zone = await get_default_calendar_timezone(client)
|
||||||
|
request_config = create_timezone_request_config(time_zone)
|
||||||
|
|
||||||
|
response = await client.me.events.by_event_id(event_id).get(
|
||||||
|
request_configuration=request_config
|
||||||
|
)
|
||||||
|
|
||||||
|
return Event.from_sdk(response).to_dict() # type: ignore[arg-type]
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from arcade.sdk import ToolContext, tool
|
||||||
|
from arcade.sdk.auth import Microsoft
|
||||||
|
from msgraph.generated.users.item.calendar.calendar_view.calendar_view_request_builder import (
|
||||||
|
CalendarViewRequestBuilder,
|
||||||
|
)
|
||||||
|
|
||||||
|
from arcade_microsoft.client import get_client
|
||||||
|
from arcade_microsoft.outlook_calendar._utils import (
|
||||||
|
convert_timezone_to_offset,
|
||||||
|
create_timezone_request_config,
|
||||||
|
get_default_calendar_timezone,
|
||||||
|
replace_timezone_offset,
|
||||||
|
validate_date_times,
|
||||||
|
)
|
||||||
|
from arcade_microsoft.outlook_calendar.models import Event
|
||||||
|
|
||||||
|
|
||||||
|
@tool(requires_auth=Microsoft(scopes=["MailboxSettings.Read", "Calendars.ReadBasic"]))
|
||||||
|
async def list_events_in_time_range(
|
||||||
|
context: ToolContext,
|
||||||
|
start_date_time: Annotated[
|
||||||
|
str,
|
||||||
|
"The start date and time of the time range, represented in "
|
||||||
|
"ISO 8601 format. Timezone offset is ignored. For example, 2025-04-24T19:00:00",
|
||||||
|
],
|
||||||
|
end_date_time: Annotated[
|
||||||
|
str,
|
||||||
|
"The end date and time of the time range, represented in "
|
||||||
|
"ISO 8601 format. Timezone offset is ignored. For example, 2025-04-24T19:30:00",
|
||||||
|
],
|
||||||
|
limit: Annotated[int, "The maximum number of events to return. Max 1000. Defaults to 10"] = 10,
|
||||||
|
) -> Annotated[dict, "A dictionary containing a list of events"]:
|
||||||
|
"""List events in the user's calendar in a specific time range.
|
||||||
|
|
||||||
|
Ignores timezone offsets provided in the start_date_time and end_date_time parameters.
|
||||||
|
Instead, uses the user's default calendar timezone to filter events.
|
||||||
|
If the user has not set a timezone for their calendar, then the timezone will be UTC.
|
||||||
|
"""
|
||||||
|
# Validate inputs
|
||||||
|
validate_date_times(start_date_time, end_date_time)
|
||||||
|
|
||||||
|
client = get_client(context.get_auth_token_or_empty())
|
||||||
|
time_zone = await get_default_calendar_timezone(client)
|
||||||
|
time_zone_offset = convert_timezone_to_offset(time_zone)
|
||||||
|
start_date_time = replace_timezone_offset(start_date_time, time_zone_offset)
|
||||||
|
end_date_time = replace_timezone_offset(end_date_time, time_zone_offset)
|
||||||
|
query_params = CalendarViewRequestBuilder.CalendarViewRequestBuilderGetQueryParameters(
|
||||||
|
start_date_time=start_date_time,
|
||||||
|
end_date_time=end_date_time,
|
||||||
|
top=max(1, min(limit, 1000)),
|
||||||
|
)
|
||||||
|
request_config = create_timezone_request_config(time_zone, query_params)
|
||||||
|
|
||||||
|
response = await client.me.calendar.calendar_view.get(request_config)
|
||||||
|
events = [Event.from_sdk(event).to_dict() for event in response.value] # type: ignore[union-attr]
|
||||||
|
|
||||||
|
return {"events": events, "num_events": len(events)}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
|
|
||||||
|
|
||||||
DEFAULT_MESSAGE_FIELDS = [
|
DEFAULT_MESSAGE_FIELDS = [
|
||||||
"bccRecipients",
|
"bccRecipients",
|
||||||
"body",
|
"body",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
get_event_additional_messages = [
|
||||||
|
{"role": "system", "content": "Today is 2025-04-22, Tuesday."},
|
||||||
|
{"role": "user", "content": "show me my meetings for today"},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call_def456",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "Microsoft_ListEventsInTimeRange",
|
||||||
|
"arguments": '{"start_date_time":"2025-04-22T00:00:00","end_date_time":"2025-04-22T23:59:59"}', # noqa: E501
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": '{"events":[{"attendees":[{"email":"john@example.com","name":"John Smith"},{"email":"alice@example.com","name":"Alice Johnson"}],"body":"Quarterly review meeting","end":{"date_time":"2025-04-22T15:00:00.0000000","time_zone":"Pacific Standard Time"},"has_attachments":true,"id":"AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4","importance":"high","is_all_day":false,"is_cancelled":false,"is_online_meeting":true,"is_organizer":true,"location":"Online","online_meeting_url":"https://teams.microsoft.com/l/meetup-join/meeting_id","organizer":{"email":"user@example.com","name":"User Name"},"start":{"date_time":"2025-04-22T14:00:00.0000000","time_zone":"Pacific Standard Time"},"subject":"Q1 Review","web_link":"https://outlook.office365.com/owa/?itemid=AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4&exvsurl=1&path=/calendar/item"}],"num_events":1}', # noqa: E501
|
||||||
|
"tool_call_id": "call_def456",
|
||||||
|
"name": "Microsoft_ListEventsInTimeRange",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "You have 1 meeting scheduled for today:\n\n1. **Q1 Review** - Today, 2:00 PM - 3:00 PM\n Location: Online\n Attendees: John Smith, Alice Johnson\n This is a high importance meeting with attachments.", # noqa: E501
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
from arcade.sdk import ToolCatalog
|
||||||
|
from arcade.sdk.eval import (
|
||||||
|
BinaryCritic,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedToolCall,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
from arcade.sdk.eval.critic import SimilarityCritic
|
||||||
|
|
||||||
|
from arcade_microsoft.outlook_calendar import create_event
|
||||||
|
|
||||||
|
# Evaluation rubric
|
||||||
|
rubric = EvalRubric(
|
||||||
|
fail_threshold=0.9,
|
||||||
|
warn_threshold=0.95,
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = ToolCatalog()
|
||||||
|
catalog.add_tool(create_event, "Microsoft")
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
def outlook_calendar_create_event_eval_suite() -> EvalSuite:
|
||||||
|
"""Create an evaluation suite for Outlook Calendar create event tool."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Outlook Calendar Create Event Evaluation",
|
||||||
|
system_message=(
|
||||||
|
"You are an AI that has access to tools to view and manage calendar events. "
|
||||||
|
"The current time date and time is April 25, 2025, 5:18 PM PST."
|
||||||
|
),
|
||||||
|
catalog=catalog,
|
||||||
|
rubric=rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Create virtual event",
|
||||||
|
user_message=(
|
||||||
|
"schedule a virtual team meeting 'Standup' tomorrow at 3pm for 1 hour. "
|
||||||
|
"john@example.com and sarah@example.com need to be there"
|
||||||
|
),
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=create_event,
|
||||||
|
args={
|
||||||
|
"subject": "Standup",
|
||||||
|
"start_date_time": "2025-04-26T15:00:00",
|
||||||
|
"end_date_time": "2025-04-26T16:00:00",
|
||||||
|
"attendee_emails": ["john@example.com", "sarah@example.com"],
|
||||||
|
"is_online_meeting": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
SimilarityCritic(critic_field="subject", weight=1 / 5),
|
||||||
|
BinaryCritic(critic_field="start_date_time", weight=1 / 5),
|
||||||
|
BinaryCritic(critic_field="end_date_time", weight=1 / 5),
|
||||||
|
BinaryCritic(critic_field="attendee_emails", weight=1 / 5),
|
||||||
|
BinaryCritic(critic_field="is_online_meeting", weight=1 / 5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Create event with physical location and virtual link",
|
||||||
|
user_message=(
|
||||||
|
"schedule a team meeting 'All hands' tomorrow at 3pm for 1 hour. "
|
||||||
|
"john@example.com and sarah@example.com need to be there. "
|
||||||
|
"The meeting will be in Conference Room A, but there will be a virtual link "
|
||||||
|
"for those who cannot attend in person."
|
||||||
|
),
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=create_event,
|
||||||
|
args={
|
||||||
|
"subject": "All hands",
|
||||||
|
"start_date_time": "2025-04-26T15:00:00",
|
||||||
|
"end_date_time": "2025-04-26T16:00:00",
|
||||||
|
"location": "Conference Room A",
|
||||||
|
"attendee_emails": ["john@example.com", "sarah@example.com"],
|
||||||
|
"is_online_meeting": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
SimilarityCritic(critic_field="subject", weight=1 / 6),
|
||||||
|
BinaryCritic(critic_field="start_date_time", weight=1 / 6),
|
||||||
|
BinaryCritic(critic_field="end_date_time", weight=1 / 6),
|
||||||
|
SimilarityCritic(critic_field="location", weight=1 / 6),
|
||||||
|
BinaryCritic(critic_field="attendee_emails", weight=1 / 6),
|
||||||
|
BinaryCritic(critic_field="is_online_meeting", weight=1 / 6),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
53
toolkits/microsoft/evals/outlook_calendar/eval_get_event.py
Normal file
53
toolkits/microsoft/evals/outlook_calendar/eval_get_event.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
from arcade.sdk import ToolCatalog
|
||||||
|
from arcade.sdk.eval import (
|
||||||
|
BinaryCritic,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedToolCall,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
|
||||||
|
from arcade_microsoft.outlook_calendar import get_event
|
||||||
|
from evals.outlook_calendar.additional_messages import get_event_additional_messages
|
||||||
|
|
||||||
|
# Evaluation rubric
|
||||||
|
rubric = EvalRubric(
|
||||||
|
fail_threshold=0.9,
|
||||||
|
warn_threshold=0.95,
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = ToolCatalog()
|
||||||
|
catalog.add_tool(get_event, "Microsoft")
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
def outlook_calendar_get_event_eval_suite() -> EvalSuite:
|
||||||
|
"""Create an evaluation suite for Outlook Calendar get event tool."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Outlook Calendar Get Event Evaluation",
|
||||||
|
system_message=(
|
||||||
|
"You are an AI that has access to tools to view and manage calendar events. "
|
||||||
|
"The current time date and time is April 25, 2025, 5:18 PM PST."
|
||||||
|
),
|
||||||
|
catalog=catalog,
|
||||||
|
rubric=rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get event by id after listing events",
|
||||||
|
user_message="tell me more about the first event",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_event,
|
||||||
|
args={
|
||||||
|
"event_id": "AAMkADAwATM0MDAAMi04Y2Y1LTQ3MTEALTAwAi0wMAoARgAAAyXxSd3UxTpCkDpGouEg0JMBAFuxokOLZRtDncM4", # noqa: E501
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="event_id", weight=1.0),
|
||||||
|
],
|
||||||
|
additional_messages=get_event_additional_messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
from arcade.sdk import ToolCatalog
|
||||||
|
from arcade.sdk.eval import (
|
||||||
|
BinaryCritic,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedToolCall,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
from arcade.sdk.eval.critic import DatetimeCritic
|
||||||
|
|
||||||
|
from arcade_microsoft.outlook_calendar import list_events_in_time_range
|
||||||
|
|
||||||
|
# Evaluation rubric
|
||||||
|
rubric = EvalRubric(
|
||||||
|
fail_threshold=0.9,
|
||||||
|
warn_threshold=0.95,
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = ToolCatalog()
|
||||||
|
catalog.add_tool(list_events_in_time_range, "Microsoft")
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
def outlook_calendar_list_events_in_time_range_eval_suite() -> EvalSuite:
|
||||||
|
"""Create an evaluation suite for Outlook Calendar list events tool."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Outlook Calendar List Events Evaluation",
|
||||||
|
system_message=(
|
||||||
|
"You are an AI that has access to tools to view and manage calendar events. "
|
||||||
|
"The current time date and time is Friday, April 25, 2025, 5:18 PM PST."
|
||||||
|
),
|
||||||
|
catalog=catalog,
|
||||||
|
rubric=rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="List events in time range",
|
||||||
|
user_message="what are my meetings on monday",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=list_events_in_time_range,
|
||||||
|
args={
|
||||||
|
"start_date_time": "2025-04-28T00:00:00",
|
||||||
|
"end_date_time": "2025-04-28T23:59:59",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
DatetimeCritic(critic_field="start_date_time", weight=0.5),
|
||||||
|
DatetimeCritic(critic_field="end_date_time", weight=0.5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="List events in time range with limit",
|
||||||
|
user_message=(
|
||||||
|
"get my first 10 meetings for the next work-week through thursday, "
|
||||||
|
"starting tuesday (mon is holiday)"
|
||||||
|
),
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=list_events_in_time_range,
|
||||||
|
args={
|
||||||
|
"start_date_time": "2025-04-29T00:00:00",
|
||||||
|
"end_date_time": "2025-05-01T23:59:59",
|
||||||
|
"limit": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
DatetimeCritic(critic_field="start_date_time", weight=0.3),
|
||||||
|
DatetimeCritic(critic_field="end_date_time", weight=0.3),
|
||||||
|
BinaryCritic(critic_field="limit", weight=0.4),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "arcade_microsoft"
|
name = "arcade_microsoft"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "Arcade.dev LLM tools for Outlook Mail"
|
description = "Arcade.dev LLM tools for Outlook Mail"
|
||||||
authors = ["Arcade <dev@arcade.dev>"]
|
authors = ["Arcade <dev@arcade.dev>"]
|
||||||
|
|
||||||
|
|
@ -9,6 +9,7 @@ python = "^3.10"
|
||||||
arcade-ai = "^1.3.2"
|
arcade-ai = "^1.3.2"
|
||||||
msgraph-sdk = "^1.28.0"
|
msgraph-sdk = "^1.28.0"
|
||||||
beautifulsoup4 = "^4.10.0"
|
beautifulsoup4 = "^4.10.0"
|
||||||
|
pytz = "^2024.2"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^8.3.0"
|
pytest = "^8.3.0"
|
||||||
|
|
|
||||||
0
toolkits/microsoft/tests/outlook_calendar/__init__.py
Normal file
0
toolkits/microsoft/tests/outlook_calendar/__init__.py
Normal file
385
toolkits/microsoft/tests/outlook_calendar/test_models.py
Normal file
385
toolkits/microsoft/tests/outlook_calendar/test_models.py
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
import pytest
|
||||||
|
from msgraph.generated.models.attendee import Attendee as GraphAttendee
|
||||||
|
from msgraph.generated.models.date_time_time_zone import DateTimeTimeZone as GraphDateTimeTimeZone
|
||||||
|
from msgraph.generated.models.email_address import EmailAddress as GraphEmailAddress
|
||||||
|
from msgraph.generated.models.event import Event as GraphEvent
|
||||||
|
from msgraph.generated.models.location import Location as GraphLocation
|
||||||
|
from msgraph.generated.models.recipient import Recipient as GraphRecipient
|
||||||
|
from msgraph.generated.models.response_status import ResponseStatus as GraphResponseStatus
|
||||||
|
from msgraph.generated.models.response_type import ResponseType as GraphResponseType
|
||||||
|
|
||||||
|
from arcade_microsoft.outlook_calendar.models import (
|
||||||
|
Attendee,
|
||||||
|
DateTimeTimeZone,
|
||||||
|
Event,
|
||||||
|
Organizer,
|
||||||
|
ResponseStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DummyBody:
|
||||||
|
def __init__(self, content):
|
||||||
|
self.content = content
|
||||||
|
|
||||||
|
|
||||||
|
class DummyEventType:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class DummyImportance:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class DummyFreeBusyStatus:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class DummyResponseType:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_data, expected",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"name": "John Doe", "address": "john.doe@example.com", "response": "accepted"},
|
||||||
|
{"name": "John Doe", "address": "john.doe@example.com", "response": "accepted"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"name": "", "address": "anonymous@example.com", "response": "tentativelyAccepted"},
|
||||||
|
{"name": "", "address": "anonymous@example.com", "response": "tentativelyAccepted"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"name": None, "address": None, "response": "none"},
|
||||||
|
{"name": "", "address": "", "response": "none"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_attendee_conversion(input_data, expected):
|
||||||
|
sdk_attendee = GraphAttendee()
|
||||||
|
sdk_attendee.email_address = GraphEmailAddress()
|
||||||
|
sdk_attendee.email_address.name = input_data["name"]
|
||||||
|
sdk_attendee.email_address.address = input_data["address"]
|
||||||
|
sdk_attendee.status = GraphResponseStatus()
|
||||||
|
sdk_attendee.status.response = GraphResponseType(input_data["response"])
|
||||||
|
|
||||||
|
# Test from_sdk method
|
||||||
|
attendee = Attendee.from_sdk(sdk_attendee)
|
||||||
|
assert attendee.name == expected["name"]
|
||||||
|
assert attendee.address == expected["address"]
|
||||||
|
assert attendee.response == expected["response"]
|
||||||
|
|
||||||
|
# Test to_dict method
|
||||||
|
dict_result = attendee.to_dict()
|
||||||
|
assert dict_result == expected
|
||||||
|
|
||||||
|
# Test to_sdk method
|
||||||
|
sdk_result = attendee.to_sdk()
|
||||||
|
assert sdk_result.email_address.name == expected["name"]
|
||||||
|
assert sdk_result.email_address.address == expected["address"]
|
||||||
|
assert sdk_result.status.response == GraphResponseType(expected["response"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_data, expected",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"name": "Jane Smith", "address": "jane.smith@example.com"},
|
||||||
|
{"name": "Jane Smith", "address": "jane.smith@example.com"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"name": "", "address": "unknown@example.com"},
|
||||||
|
{"name": "", "address": "unknown@example.com"},
|
||||||
|
),
|
||||||
|
({"name": None, "address": None}, {"name": "", "address": ""}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_organizer_conversion(input_data, expected):
|
||||||
|
sdk_organizer = GraphRecipient()
|
||||||
|
sdk_organizer.email_address = GraphEmailAddress()
|
||||||
|
sdk_organizer.email_address.name = input_data["name"]
|
||||||
|
sdk_organizer.email_address.address = input_data["address"]
|
||||||
|
|
||||||
|
# Test from_sdk method
|
||||||
|
organizer = Organizer.from_sdk(sdk_organizer)
|
||||||
|
assert organizer.name == expected["name"]
|
||||||
|
assert organizer.address == expected["address"]
|
||||||
|
|
||||||
|
# Test to_dict method
|
||||||
|
dict_result = organizer.to_dict()
|
||||||
|
assert dict_result == expected
|
||||||
|
|
||||||
|
# Test to_sdk method
|
||||||
|
sdk_result = organizer.to_sdk()
|
||||||
|
assert sdk_result.email_address.name == expected["name"]
|
||||||
|
assert sdk_result.email_address.address == expected["address"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_data, expected",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"date_time": "2023-05-10T14:00:00", "time_zone": "Pacific Standard Time"},
|
||||||
|
{"dateTime": "2023-05-10T14:00:00", "timeZone": "Pacific Standard Time"},
|
||||||
|
),
|
||||||
|
({"date_time": "", "time_zone": "UTC"}, {"dateTime": "", "timeZone": "UTC"}),
|
||||||
|
({"date_time": None, "time_zone": None}, {"dateTime": "", "timeZone": ""}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_date_time_time_zone_conversion(input_data, expected):
|
||||||
|
sdk_date_time = GraphDateTimeTimeZone()
|
||||||
|
sdk_date_time.date_time = input_data["date_time"]
|
||||||
|
sdk_date_time.time_zone = input_data["time_zone"]
|
||||||
|
|
||||||
|
# Test from_sdk method
|
||||||
|
date_time_tz = DateTimeTimeZone.from_sdk(sdk_date_time)
|
||||||
|
assert date_time_tz.date_time == (input_data["date_time"] or "")
|
||||||
|
assert date_time_tz.time_zone == (input_data["time_zone"] or "")
|
||||||
|
|
||||||
|
# Test to_dict method
|
||||||
|
dict_result = date_time_tz.to_dict()
|
||||||
|
assert dict_result == expected
|
||||||
|
|
||||||
|
# Test to_sdk method
|
||||||
|
sdk_result = date_time_tz.to_sdk()
|
||||||
|
assert sdk_result.date_time == date_time_tz.date_time
|
||||||
|
assert sdk_result.time_zone == date_time_tz.time_zone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_data, expected",
|
||||||
|
[
|
||||||
|
({"response": "accepted"}, {"response": "accepted"}),
|
||||||
|
({"response": "declined"}, {"response": "declined"}),
|
||||||
|
({"response": "none"}, {"response": "none"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_response_status_conversion(input_data, expected):
|
||||||
|
sdk_response_status = GraphResponseStatus()
|
||||||
|
sdk_response_status.response = GraphResponseType(input_data["response"])
|
||||||
|
|
||||||
|
# Test from_sdk method
|
||||||
|
response_status = ResponseStatus.from_sdk(sdk_response_status)
|
||||||
|
assert response_status.response == expected["response"]
|
||||||
|
|
||||||
|
# Test to_dict method
|
||||||
|
dict_result = response_status.to_dict()
|
||||||
|
assert dict_result == expected
|
||||||
|
|
||||||
|
# Test to_sdk method
|
||||||
|
sdk_result = response_status.to_sdk()
|
||||||
|
assert sdk_result.response == GraphResponseType(expected["response"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_data, expected",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"body_content": "<p>Team <b>Meeting</b></p>",
|
||||||
|
"has_attachments": True,
|
||||||
|
"importance": "high",
|
||||||
|
"is_all_day": False,
|
||||||
|
"is_cancelled": False,
|
||||||
|
"is_draft": False,
|
||||||
|
"is_online_meeting": True,
|
||||||
|
"is_organizer": True,
|
||||||
|
"location": "Conference Room A",
|
||||||
|
"online_meeting_url": "https://teams.microsoft.com/l/meetup-join/123",
|
||||||
|
"id": "event-123",
|
||||||
|
"show_as": "busy",
|
||||||
|
"subject": "Weekly Team Sync",
|
||||||
|
"type": "singleInstance",
|
||||||
|
"web_link": "https://outlook.office.com/calendar/item/123",
|
||||||
|
"attendees": [
|
||||||
|
{"name": "Alice", "address": "alice@example.com", "response": "accepted"},
|
||||||
|
{
|
||||||
|
"name": "Bob",
|
||||||
|
"address": "bob@example.com",
|
||||||
|
"response": "tentativelyAccepted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"organizer": {"name": "Manager", "address": "manager@example.com"},
|
||||||
|
"start": {"date_time": "2023-05-10T10:00:00", "time_zone": "Eastern Standard Time"},
|
||||||
|
"end": {"date_time": "2023-05-10T11:00:00", "time_zone": "Eastern Standard Time"},
|
||||||
|
"response_status": {"response": "accepted"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": "Team Meeting",
|
||||||
|
"has_attachments": True,
|
||||||
|
"importance": "high",
|
||||||
|
"is_all_day": False,
|
||||||
|
"is_cancelled": False,
|
||||||
|
"is_draft": False,
|
||||||
|
"is_online_meeting": True,
|
||||||
|
"is_organizer": True,
|
||||||
|
"location": "Conference Room A",
|
||||||
|
"online_meeting_url": "https://teams.microsoft.com/l/meetup-join/123",
|
||||||
|
"id": "event-123",
|
||||||
|
"show_as": "busy",
|
||||||
|
"subject": "Weekly Team Sync",
|
||||||
|
"type": "singleInstance",
|
||||||
|
"web_link": "https://outlook.office.com/calendar/item/123",
|
||||||
|
"event_id": "event-123",
|
||||||
|
"attendees": [
|
||||||
|
{"name": "Alice", "address": "alice@example.com", "response": "accepted"},
|
||||||
|
{
|
||||||
|
"name": "Bob",
|
||||||
|
"address": "bob@example.com",
|
||||||
|
"response": "tentativelyAccepted",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"organizer": {"name": "Manager", "address": "manager@example.com"},
|
||||||
|
"start": {"dateTime": "2023-05-10T10:00:00", "timeZone": "Eastern Standard Time"},
|
||||||
|
"end": {"dateTime": "2023-05-10T11:00:00", "timeZone": "Eastern Standard Time"},
|
||||||
|
"response_status": {"response": "accepted"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"body_content": "<p>All day <i>event</i> description</p>",
|
||||||
|
"has_attachments": False,
|
||||||
|
"importance": "normal",
|
||||||
|
"is_all_day": True,
|
||||||
|
"is_cancelled": True,
|
||||||
|
"is_draft": True,
|
||||||
|
"is_online_meeting": False,
|
||||||
|
"is_organizer": False,
|
||||||
|
"location": "",
|
||||||
|
"online_meeting_url": "",
|
||||||
|
"id": "event-456",
|
||||||
|
"show_as": "free",
|
||||||
|
"subject": "Company Holiday",
|
||||||
|
"type": "occurrence",
|
||||||
|
"web_link": "https://outlook.office.com/calendar/item/456",
|
||||||
|
"attendees": [],
|
||||||
|
"organizer": {"name": "HR Department", "address": "hr@example.com"},
|
||||||
|
"start": {"date_time": "2023-07-04T00:00:00", "time_zone": "UTC"},
|
||||||
|
"end": {"date_time": "2023-07-05T00:00:00", "time_zone": "UTC"},
|
||||||
|
"response_status": {"response": "notResponded"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"body": "All day event description",
|
||||||
|
"has_attachments": False,
|
||||||
|
"importance": "normal",
|
||||||
|
"is_all_day": True,
|
||||||
|
"is_cancelled": True,
|
||||||
|
"is_draft": True,
|
||||||
|
"is_online_meeting": False,
|
||||||
|
"is_organizer": False,
|
||||||
|
"location": "",
|
||||||
|
"online_meeting_url": "",
|
||||||
|
"id": "event-456",
|
||||||
|
"show_as": "free",
|
||||||
|
"subject": "Company Holiday",
|
||||||
|
"type": "occurrence",
|
||||||
|
"web_link": "https://outlook.office.com/calendar/item/456",
|
||||||
|
"event_id": "event-456",
|
||||||
|
"attendees": [],
|
||||||
|
"organizer": {"name": "HR Department", "address": "hr@example.com"},
|
||||||
|
"start": {"dateTime": "2023-07-04T00:00:00", "timeZone": "UTC"},
|
||||||
|
"end": {"dateTime": "2023-07-05T00:00:00", "timeZone": "UTC"},
|
||||||
|
"response_status": {"response": "notResponded"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_event_conversion(input_data, expected):
|
||||||
|
def make_graph_attendee(attendee_data):
|
||||||
|
attendee = GraphAttendee()
|
||||||
|
attendee.email_address = GraphEmailAddress()
|
||||||
|
attendee.email_address.name = attendee_data.get("name", "")
|
||||||
|
attendee.email_address.address = attendee_data.get("address", "")
|
||||||
|
attendee.status = GraphResponseStatus()
|
||||||
|
attendee.status.response = GraphResponseType(attendee_data.get("response", ""))
|
||||||
|
return attendee
|
||||||
|
|
||||||
|
def make_graph_organizer(organizer_data):
|
||||||
|
organizer = GraphRecipient()
|
||||||
|
organizer.email_address = GraphEmailAddress()
|
||||||
|
organizer.email_address.name = organizer_data.get("name", "")
|
||||||
|
organizer.email_address.address = organizer_data.get("address", "")
|
||||||
|
return organizer
|
||||||
|
|
||||||
|
def make_graph_date_time(date_time_data):
|
||||||
|
date_time = GraphDateTimeTimeZone()
|
||||||
|
date_time.date_time = date_time_data.get("date_time", "")
|
||||||
|
date_time.time_zone = date_time_data.get("time_zone", "")
|
||||||
|
return date_time
|
||||||
|
|
||||||
|
sdk_event = GraphEvent()
|
||||||
|
sdk_event.body = DummyBody(input_data["body_content"])
|
||||||
|
sdk_event.has_attachments = input_data["has_attachments"]
|
||||||
|
sdk_event.importance = DummyImportance(input_data["importance"])
|
||||||
|
sdk_event.is_all_day = input_data["is_all_day"]
|
||||||
|
sdk_event.is_cancelled = input_data["is_cancelled"]
|
||||||
|
sdk_event.is_draft = input_data["is_draft"]
|
||||||
|
sdk_event.is_online_meeting = input_data["is_online_meeting"]
|
||||||
|
sdk_event.is_organizer = input_data["is_organizer"]
|
||||||
|
sdk_event.location = GraphLocation(display_name=input_data["location"])
|
||||||
|
sdk_event.online_meeting_url = input_data["online_meeting_url"]
|
||||||
|
sdk_event.id = input_data["id"]
|
||||||
|
sdk_event.show_as = DummyFreeBusyStatus(input_data["show_as"])
|
||||||
|
sdk_event.subject = input_data["subject"]
|
||||||
|
sdk_event.type = DummyEventType(input_data["type"])
|
||||||
|
sdk_event.web_link = input_data["web_link"]
|
||||||
|
sdk_event.attendees = [make_graph_attendee(a) for a in input_data["attendees"]]
|
||||||
|
sdk_event.organizer = make_graph_organizer(input_data["organizer"])
|
||||||
|
sdk_event.start = make_graph_date_time(input_data["start"])
|
||||||
|
sdk_event.end = make_graph_date_time(input_data["end"])
|
||||||
|
sdk_event.response_status = GraphResponseStatus()
|
||||||
|
sdk_event.response_status.response = GraphResponseType(
|
||||||
|
input_data["response_status"]["response"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test from_sdk method
|
||||||
|
event = Event.from_sdk(sdk_event)
|
||||||
|
assert event.body == expected["body"]
|
||||||
|
assert event.has_attachments == expected["has_attachments"]
|
||||||
|
assert event.importance == expected["importance"]
|
||||||
|
assert event.is_all_day == expected["is_all_day"]
|
||||||
|
assert event.is_cancelled == expected["is_cancelled"]
|
||||||
|
assert event.is_draft == expected["is_draft"]
|
||||||
|
assert event.is_online_meeting == expected["is_online_meeting"]
|
||||||
|
assert event.is_organizer == expected["is_organizer"]
|
||||||
|
assert event.location == expected["location"]
|
||||||
|
assert event.online_meeting_url == expected["online_meeting_url"]
|
||||||
|
assert event.id == expected["id"]
|
||||||
|
assert event.show_as == expected["show_as"]
|
||||||
|
assert event.subject == expected["subject"]
|
||||||
|
assert event.type == expected["type"]
|
||||||
|
assert event.web_link == expected["web_link"]
|
||||||
|
assert event.event_id == expected["event_id"]
|
||||||
|
assert len(event.attendees) == len(expected["attendees"])
|
||||||
|
for i, attendee in enumerate(event.attendees):
|
||||||
|
assert attendee.name == expected["attendees"][i]["name"]
|
||||||
|
assert attendee.address == expected["attendees"][i]["address"]
|
||||||
|
assert attendee.response == expected["attendees"][i]["response"]
|
||||||
|
if event.start:
|
||||||
|
assert event.start.date_time == expected["start"]["dateTime"]
|
||||||
|
assert event.start.time_zone == expected["start"]["timeZone"]
|
||||||
|
if event.end:
|
||||||
|
assert event.end.date_time == expected["end"]["dateTime"]
|
||||||
|
assert event.end.time_zone == expected["end"]["timeZone"]
|
||||||
|
if event.organizer:
|
||||||
|
assert event.organizer.name == expected["organizer"]["name"]
|
||||||
|
assert event.organizer.address == expected["organizer"]["address"]
|
||||||
|
if event.response_status:
|
||||||
|
assert event.response_status.response == expected["response_status"]["response"]
|
||||||
|
|
||||||
|
# Test to_dict method
|
||||||
|
dict_result = event.to_dict()
|
||||||
|
assert dict_result["body"] == expected["body"]
|
||||||
|
assert dict_result["subject"] == expected["subject"]
|
||||||
|
assert dict_result["event_id"] == expected["event_id"]
|
||||||
|
|
||||||
|
# Test to_sdk method
|
||||||
|
sdk_result = event.to_sdk()
|
||||||
|
assert sdk_result.subject == event.subject
|
||||||
|
assert sdk_result.is_all_day == event.is_all_day
|
||||||
|
assert sdk_result.location.display_name == event.location
|
||||||
|
assert len(sdk_result.attendees) == len(event.attendees)
|
||||||
118
toolkits/microsoft/tests/outlook_calendar/test_utils.py
Normal file
118
toolkits/microsoft/tests/outlook_calendar/test_utils.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import pytest
|
||||||
|
from arcade.core.errors import ToolExecutionError
|
||||||
|
|
||||||
|
from arcade_microsoft.outlook_calendar._utils import (
|
||||||
|
convert_timezone_to_offset,
|
||||||
|
is_valid_email,
|
||||||
|
remove_timezone_offset,
|
||||||
|
replace_timezone_offset,
|
||||||
|
validate_date_times,
|
||||||
|
validate_emails,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"start_date_time, end_date_time, error_type",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"2026-01-01T10:00:00",
|
||||||
|
"2026-01-01T17:00:00",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
# end_date_time before start_date_time
|
||||||
|
(
|
||||||
|
"2026-01-01T10:00:00",
|
||||||
|
"2026-01-01T10:00:00",
|
||||||
|
ToolExecutionError,
|
||||||
|
),
|
||||||
|
# end_date_time before start_date_time because timezone offset is ignored
|
||||||
|
(
|
||||||
|
"2026-01-01T10:00:00-07:00",
|
||||||
|
"2026-01-01T09:00:00-08:00",
|
||||||
|
ToolExecutionError,
|
||||||
|
),
|
||||||
|
# not ISO 8601 format
|
||||||
|
(
|
||||||
|
"20260101T10:00:00",
|
||||||
|
"2026-01-0109:00:00",
|
||||||
|
ValueError,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_date_times(start_date_time, end_date_time, error_type):
|
||||||
|
if error_type:
|
||||||
|
with pytest.raises(error_type):
|
||||||
|
validate_date_times(start_date_time, end_date_time)
|
||||||
|
else:
|
||||||
|
validate_date_times(start_date_time, end_date_time)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"emails, expect_error",
|
||||||
|
[
|
||||||
|
(["test@test.com"], False),
|
||||||
|
(["test@test.com", "test@test.com.au"], False),
|
||||||
|
(["test@test.com", "test@test.com.au."], True),
|
||||||
|
(["#$&*@test.com"], True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_emails(emails, expect_error):
|
||||||
|
if expect_error:
|
||||||
|
with pytest.raises(ToolExecutionError):
|
||||||
|
validate_emails(emails)
|
||||||
|
else:
|
||||||
|
validate_emails(emails)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"email, is_valid",
|
||||||
|
[
|
||||||
|
("test@test.com", True),
|
||||||
|
("test@test", False),
|
||||||
|
("test@test.com.au", True),
|
||||||
|
("test@test.com.au.", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_valid_email(email, is_valid):
|
||||||
|
assert is_valid_email(email) == is_valid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_date_time, expected_date_time",
|
||||||
|
[
|
||||||
|
("2021-01-01T10:00:00+07:00", "2021-01-01T10:00:00"),
|
||||||
|
("2021-01-01T10:00:00-07:00", "2021-01-01T10:00:00"),
|
||||||
|
("2021-01-01T10:00:00Z", "2021-01-01T10:00:00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_remove_timezone_offset(input_date_time, expected_date_time):
|
||||||
|
assert remove_timezone_offset(input_date_time) == expected_date_time
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_date_time, time_zone_offset, expected_date_time",
|
||||||
|
[
|
||||||
|
# without existing offset
|
||||||
|
("2021-01-01T10:00:00", "+07:00", "2021-01-01T10:00:00+07:00"),
|
||||||
|
("2021-01-01T10:00:00", "-07:00", "2021-01-01T10:00:00-07:00"),
|
||||||
|
("2021-01-01T10:00:00", "Z", "2021-01-01T10:00:00Z"),
|
||||||
|
# with existing offset
|
||||||
|
("2021-01-01T10:00:00+07:00", "+04:00", "2021-01-01T10:00:00+04:00"),
|
||||||
|
("2021-01-01T10:00:00-07:00", "-09:00", "2021-01-01T10:00:00-09:00"),
|
||||||
|
("2021-01-01T10:00:00-07:00", "Z", "2021-01-01T10:00:00Z"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_replace_timezone_offset(input_date_time, time_zone_offset, expected_date_time):
|
||||||
|
assert replace_timezone_offset(input_date_time, time_zone_offset) == expected_date_time
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"time_zone, expected_offset",
|
||||||
|
[
|
||||||
|
("Central Asia Standard Time", "+05:00"), # Windows timezone format
|
||||||
|
("America/New_York", "-04:00"), # IANA timezone format
|
||||||
|
("Not a valid timezone", "Z"), # Fallback to UTC
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_convert_timezone_to_offset(time_zone, expected_offset):
|
||||||
|
assert convert_timezone_to_offset(time_zone) == expected_offset
|
||||||
Loading…
Reference in a new issue