diff --git a/toolkits/microsoft/arcade_microsoft/client.py b/toolkits/microsoft/arcade_microsoft/client.py index 7bdcc205..e11d257a 100644 --- a/toolkits/microsoft/arcade_microsoft/client.py +++ b/toolkits/microsoft/arcade_microsoft/client.py @@ -4,7 +4,7 @@ from typing import Any from azure.core.credentials import AccessToken, TokenCredential from msgraph import GraphServiceClient -from arcade_microsoft.outlook_mail.constants import DEFAULT_SCOPE +DEFAULT_SCOPE = "https://graph.microsoft.com/.default" class StaticTokenCredential(TokenCredential): diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/__init__.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/__init__.py new file mode 100644 index 00000000..4baac7ce --- /dev/null +++ b/toolkits/microsoft/arcade_microsoft/outlook_calendar/__init__.py @@ -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"] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/_utils.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/_utils.py new file mode 100644 index 00000000..3f42df8a --- /dev/null +++ b/toolkits/microsoft/arcade_microsoft/outlook_calendar/_utils.py @@ -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, + ) diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/constants.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/constants.py new file mode 100644 index 00000000..678e3771 --- /dev/null +++ b/toolkits/microsoft/arcade_microsoft/outlook_calendar/constants.py @@ -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", +} diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/models.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/models.py new file mode 100644 index 00000000..b65201b9 --- /dev/null +++ b/toolkits/microsoft/arcade_microsoft/outlook_calendar/models.py @@ -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, + ) diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/__init__.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/__init__.py new file mode 100644 index 00000000..4fcdfabb --- /dev/null +++ b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/__init__.py @@ -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"] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/create_event.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/create_event.py new file mode 100644 index 00000000..49f3c6f0 --- /dev/null +++ b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/create_event.py @@ -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] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/get_event.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/get_event.py new file mode 100644 index 00000000..a21e5627 --- /dev/null +++ b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/get_event.py @@ -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] diff --git a/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/list_events_in_time_range.py b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/list_events_in_time_range.py new file mode 100644 index 00000000..58780a78 --- /dev/null +++ b/toolkits/microsoft/arcade_microsoft/outlook_calendar/tools/list_events_in_time_range.py @@ -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)} diff --git a/toolkits/microsoft/arcade_microsoft/outlook_mail/constants.py b/toolkits/microsoft/arcade_microsoft/outlook_mail/constants.py index 628b513e..6daef4d6 100644 --- a/toolkits/microsoft/arcade_microsoft/outlook_mail/constants.py +++ b/toolkits/microsoft/arcade_microsoft/outlook_mail/constants.py @@ -1,5 +1,3 @@ -DEFAULT_SCOPE = "https://graph.microsoft.com/.default" - DEFAULT_MESSAGE_FIELDS = [ "bccRecipients", "body", diff --git a/toolkits/microsoft/evals/outlook_calendar/additional_messages.py b/toolkits/microsoft/evals/outlook_calendar/additional_messages.py new file mode 100644 index 00000000..9ea6c9a5 --- /dev/null +++ b/toolkits/microsoft/evals/outlook_calendar/additional_messages.py @@ -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 + }, +] diff --git a/toolkits/microsoft/evals/outlook_calendar/eval_create_event.py b/toolkits/microsoft/evals/outlook_calendar/eval_create_event.py new file mode 100644 index 00000000..ec1cdeec --- /dev/null +++ b/toolkits/microsoft/evals/outlook_calendar/eval_create_event.py @@ -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 diff --git a/toolkits/microsoft/evals/outlook_calendar/eval_get_event.py b/toolkits/microsoft/evals/outlook_calendar/eval_get_event.py new file mode 100644 index 00000000..ef3f8e25 --- /dev/null +++ b/toolkits/microsoft/evals/outlook_calendar/eval_get_event.py @@ -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 diff --git a/toolkits/microsoft/evals/outlook_calendar/eval_list_events_in_time_range.py b/toolkits/microsoft/evals/outlook_calendar/eval_list_events_in_time_range.py new file mode 100644 index 00000000..0a02d03b --- /dev/null +++ b/toolkits/microsoft/evals/outlook_calendar/eval_list_events_in_time_range.py @@ -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 diff --git a/toolkits/microsoft/pyproject.toml b/toolkits/microsoft/pyproject.toml index 8b2b5ff5..ce870794 100644 --- a/toolkits/microsoft/pyproject.toml +++ b/toolkits/microsoft/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcade_microsoft" -version = "0.1.0" +version = "0.2.0" description = "Arcade.dev LLM tools for Outlook Mail" authors = ["Arcade "] @@ -9,6 +9,7 @@ python = "^3.10" arcade-ai = "^1.3.2" msgraph-sdk = "^1.28.0" beautifulsoup4 = "^4.10.0" +pytz = "^2024.2" [tool.poetry.dev-dependencies] pytest = "^8.3.0" diff --git a/toolkits/microsoft/tests/outlook_calendar/__init__.py b/toolkits/microsoft/tests/outlook_calendar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/microsoft/tests/outlook_calendar/test_models.py b/toolkits/microsoft/tests/outlook_calendar/test_models.py new file mode 100644 index 00000000..c21b7f05 --- /dev/null +++ b/toolkits/microsoft/tests/outlook_calendar/test_models.py @@ -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": "

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", + "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": "

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", + "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) diff --git a/toolkits/microsoft/tests/outlook_calendar/test_utils.py b/toolkits/microsoft/tests/outlook_calendar/test_utils.py new file mode 100644 index 00000000..d58276f5 --- /dev/null +++ b/toolkits/microsoft/tests/outlook_calendar/test_utils.py @@ -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