Skip to content

Commit 419a0ac

Browse files
authored
Add Outlook Calendar tools (#382)
1 parent c128717 commit 419a0ac

18 files changed

+1592
-4
lines changed

toolkits/microsoft/arcade_microsoft/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from azure.core.credentials import AccessToken, TokenCredential
55
from msgraph import GraphServiceClient
66

7-
from arcade_microsoft.outlook_mail.constants import DEFAULT_SCOPE
7+
DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
88

99

1010
class StaticTokenCredential(TokenCredential):
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from arcade_microsoft.outlook_calendar.tools import (
2+
create_event,
3+
get_event,
4+
list_events_in_time_range,
5+
)
6+
7+
__all__ = ["create_event", "get_event", "list_events_in_time_range"]
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import re
2+
from datetime import datetime
3+
from typing import Any
4+
5+
import pytz
6+
from arcade.sdk.errors import ToolExecutionError
7+
from kiota_abstractions.base_request_configuration import RequestConfiguration
8+
from kiota_abstractions.headers_collection import HeadersCollection
9+
from msgraph import GraphServiceClient
10+
from msgraph.generated.users.item.mailbox_settings.mailbox_settings_request_builder import (
11+
MailboxSettingsRequestBuilder,
12+
)
13+
14+
from arcade_microsoft.outlook_calendar.constants import WINDOWS_TO_IANA
15+
16+
17+
def validate_date_times(start_date_time: str, end_date_time: str) -> None:
18+
"""
19+
Validate date times are in ISO 8601 format and
20+
that end time is after start time (ignoring timezone offsets).
21+
22+
Args:
23+
start_date_time: The start date time string to validate.
24+
end_date_time: The end date time string to validate.
25+
26+
Raises:
27+
ValueError: If the date times are not in ISO 8601 format
28+
ToolExecutionError: If end time is not after start time.
29+
30+
Note:
31+
This function ignores timezone offsets.
32+
"""
33+
# parse into offset-aware datetimes
34+
start_aware = datetime.fromisoformat(start_date_time)
35+
end_aware = datetime.fromisoformat(end_date_time)
36+
37+
# drop tzinfo to treat both as naïve local times
38+
start_naive = start_aware.replace(tzinfo=None)
39+
end_naive = end_aware.replace(tzinfo=None)
40+
41+
if start_naive >= end_naive:
42+
raise ToolExecutionError(
43+
message="Start time must be before end time",
44+
developer_message=(
45+
f"The start time '{start_naive}' is not before the end time '{end_naive}'"
46+
),
47+
)
48+
49+
50+
def prepare_meeting_body(
51+
body: str, custom_meeting_url: str | None, is_online_meeting: bool
52+
) -> tuple[str, bool]:
53+
"""Prepare meeting body and determine final online meeting status.
54+
55+
Args:
56+
body: The original meeting body text
57+
custom_meeting_url: Custom URL for the meeting, if one exists
58+
is_online_meeting: Whether this should be an online meeting
59+
60+
Returns:
61+
tuple: (Updated meeting body, final online meeting status)
62+
63+
Note:
64+
If a custom meeting URL is provided, is_online_meeting will be set to False
65+
to prevent Microsoft from generating its own meeting URL. The custom meeting
66+
URL will then be added to the body of the meeting.
67+
"""
68+
is_online_meeting = not custom_meeting_url and is_online_meeting
69+
70+
if custom_meeting_url:
71+
body = f"""{body}\n
72+
.........................................................................
73+
Join online meeting
74+
{custom_meeting_url}"""
75+
76+
return body, is_online_meeting
77+
78+
79+
def validate_emails(emails: list[str]) -> None:
80+
"""Validate a list of email addresses.
81+
82+
Args:
83+
emails: The list of email addresses to validate.
84+
85+
Raises:
86+
ToolExecutionError: If any email address is invalid.
87+
"""
88+
invalid_emails = []
89+
for email in emails:
90+
if not is_valid_email(email):
91+
invalid_emails.append(email)
92+
if invalid_emails:
93+
raise ToolExecutionError(message=f"Invalid email address(es): {', '.join(invalid_emails)}")
94+
95+
96+
def is_valid_email(email: str) -> bool:
97+
"""Simple check to see if an email address is valid.
98+
99+
Args:
100+
email: The email address to check.
101+
102+
Returns:
103+
True if the email address is valid, False otherwise.
104+
"""
105+
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
106+
return re.match(pattern, email) is not None
107+
108+
109+
def remove_timezone_offset(date_time: str) -> str:
110+
"""Remove the timezone offset from the date_time string."""
111+
return re.sub(r"[+-][0-9]{2}:[0-9]{2}$|Z$", "", date_time)
112+
113+
114+
def replace_timezone_offset(date_time: str, time_zone_offset: str) -> str:
115+
"""Replace the timezone offset in the date_time string with the time_zone_offset.
116+
117+
If the date_time str already contains a timezone offset, it will be replaced.
118+
If the date_time str does not contain a timezone offset, the time_zone_offset will be appended
119+
120+
Args:
121+
date_time: The date_time string to replace the timezone offset in.
122+
time_zone_offset: The timezone offset to replace the existing timezone offset with.
123+
124+
Returns:
125+
The date_time string with the timezone offset replaced or appended.
126+
"""
127+
date_time = remove_timezone_offset(date_time)
128+
return f"{date_time}{time_zone_offset}"
129+
130+
131+
def convert_timezone_to_offset(time_zone: str) -> str:
132+
"""
133+
Convert a timezone (Windows or IANA) to ISO 8601 offset.
134+
First tries Windows timezone format, then IANA, then falls back to UTC if both fail.
135+
136+
Args:
137+
time_zone: The timezone (Windows or IANA) to convert to ISO 8601 offset.
138+
139+
Returns:
140+
The timezone offset in ISO 8601 format (e.g. '+08:00', '-07:00', or 'Z' for UTC)
141+
"""
142+
# Try Windows timezone format
143+
iana_timezone = WINDOWS_TO_IANA.get(time_zone)
144+
if iana_timezone:
145+
try:
146+
tz = pytz.timezone(iana_timezone)
147+
now = datetime.now(tz)
148+
tz_offset = now.strftime("%z")
149+
150+
if len(tz_offset) == 5: # +HHMM format
151+
tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" # +HH:MM format
152+
return tz_offset # noqa: TRY300
153+
except (pytz.exceptions.UnknownTimeZoneError, ValueError):
154+
pass
155+
156+
# Try IANA timezone format
157+
try:
158+
tz = pytz.timezone(time_zone)
159+
now = datetime.now(tz)
160+
tz_offset = now.strftime("%z")
161+
162+
if len(tz_offset) == 5: # +HHMM format
163+
tz_offset = f"{tz_offset[:3]}:{tz_offset[3:]}" # +HH:MM format
164+
return tz_offset # noqa: TRY300
165+
except (pytz.exceptions.UnknownTimeZoneError, ValueError):
166+
# Fallback to UTC
167+
return "Z"
168+
169+
170+
async def get_default_calendar_timezone(client: GraphServiceClient) -> str:
171+
"""Get the authenticated user's default calendar's timezone.
172+
173+
Args:
174+
client: The GraphServiceClient to use to get
175+
the authenticated user's default calendar's timezone.
176+
177+
Returns:
178+
The timezone in "Windows timezone format" or "IANA timezone format".
179+
"""
180+
query_params = MailboxSettingsRequestBuilder.MailboxSettingsRequestBuilderGetQueryParameters(
181+
select=["timeZone"]
182+
)
183+
request_config = RequestConfiguration(
184+
query_parameters=query_params,
185+
)
186+
response = await client.me.mailbox_settings.get(request_config)
187+
188+
if response and response.time_zone:
189+
return response.time_zone
190+
return "UTC"
191+
192+
193+
def create_timezone_headers(time_zone: str) -> HeadersCollection:
194+
"""
195+
Create headers with timezone preference.
196+
197+
Args:
198+
time_zone: The timezone to set in the headers.
199+
200+
Returns:
201+
Headers collection with timezone preference set.
202+
"""
203+
headers = HeadersCollection()
204+
headers.try_add("Prefer", f'outlook.timezone="{time_zone}"')
205+
return headers
206+
207+
208+
def create_timezone_request_config(
209+
time_zone: str, query_parameters: Any | None = None
210+
) -> RequestConfiguration:
211+
"""
212+
Create a request configuration with timezone headers and optional query parameters.
213+
214+
Args:
215+
time_zone: The timezone to set in the headers.
216+
query_parameters: Optional query parameters to include in the configuration.
217+
218+
Returns:
219+
Request configuration with timezone headers and optional query parameters.
220+
"""
221+
headers = create_timezone_headers(time_zone)
222+
return RequestConfiguration(
223+
headers=headers,
224+
query_parameters=query_parameters,
225+
)
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Maps "Windows timezone format" to "IANA timezone format"
2+
# Does not include all Windows timezones.
3+
WINDOWS_TO_IANA = {
4+
"Dateline Standard Time": "Etc/GMT+12",
5+
"UTC-11": "Etc/GMT+11",
6+
"Aleutian Standard Time": "America/Adak",
7+
"Hawaiian Standard Time": "Pacific/Honolulu",
8+
"Marquesas Standard Time": "Pacific/Marquesas",
9+
"Alaskan Standard Time": "America/Anchorage",
10+
"UTC-09": "Etc/GMT+9",
11+
"Pacific Standard Time (Mexico)": "America/Tijuana",
12+
"UTC-08": "Etc/GMT+8",
13+
"Pacific Standard Time": "America/Los_Angeles",
14+
"US Mountain Standard Time": "America/Phoenix",
15+
"Mountain Standard Time (Mexico)": "America/Chihuahua",
16+
"Mountain Standard Time": "America/Denver",
17+
"Central America Standard Time": "America/Guatemala",
18+
"Central Standard Time": "America/Chicago",
19+
"Easter Island Standard Time": "Pacific/Easter",
20+
"Central Standard Time (Mexico)": "America/Mexico_City",
21+
"Canada Central Standard Time": "America/Regina",
22+
"SA Pacific Standard Time": "America/Bogota",
23+
"Eastern Standard Time (Mexico)": "America/Cancun",
24+
"Eastern Standard Time": "America/New_York",
25+
"Haiti Standard Time": "America/Port-au-Prince",
26+
"Cuba Standard Time": "America/Havana",
27+
"US Eastern Standard Time": "America/Indianapolis",
28+
"Turks And Caicos Standard Time": "America/Grand_Turk",
29+
"Paraguay Standard Time": "America/Asuncion",
30+
"Atlantic Standard Time": "America/Halifax",
31+
"Venezuela Standard Time": "America/Caracas",
32+
"Central Brazilian Standard Time": "America/Cuiaba",
33+
"SA Western Standard Time": "America/La_Paz",
34+
"Pacific SA Standard Time": "America/Santiago",
35+
"Newfoundland Standard Time": "America/St_Johns",
36+
"Tocantins Standard Time": "America/Araguaina",
37+
"E. South America Standard Time": "America/Sao_Paulo",
38+
"SA Eastern Standard Time": "America/Cayenne",
39+
"Argentina Standard Time": "America/Buenos_Aires",
40+
"Greenland Standard Time": "America/Godthab",
41+
"Montevideo Standard Time": "America/Montevideo",
42+
"Magallanes Standard Time": "America/Punta_Arenas",
43+
"Saint Pierre Standard Time": "America/Miquelon",
44+
"Bahia Standard Time": "America/Bahia",
45+
"UTC-02": "Etc/GMT+2",
46+
"Azores Standard Time": "Atlantic/Azores",
47+
"Cape Verde Standard Time": "Atlantic/Cape_Verde",
48+
"UTC": "Etc/UTC",
49+
"GMT Standard Time": "Europe/London",
50+
"Greenwich Standard Time": "Atlantic/Reykjavik",
51+
"W. Europe Standard Time": "Europe/Berlin",
52+
"Central Europe Standard Time": "Europe/Budapest",
53+
"Romance Standard Time": "Europe/Paris",
54+
"Central European Standard Time": "Europe/Warsaw",
55+
"W. Central Africa Standard Time": "Africa/Lagos",
56+
"Jordan Standard Time": "Asia/Amman",
57+
"GTB Standard Time": "Europe/Bucharest",
58+
"Middle East Standard Time": "Asia/Beirut",
59+
"Egypt Standard Time": "Africa/Cairo",
60+
"E. Europe Standard Time": "Europe/Chisinau",
61+
"Syria Standard Time": "Asia/Damascus",
62+
"West Bank Standard Time": "Asia/Hebron",
63+
"South Africa Standard Time": "Africa/Johannesburg",
64+
"FLE Standard Time": "Europe/Kiev",
65+
"Israel Standard Time": "Asia/Jerusalem",
66+
"Kaliningrad Standard Time": "Europe/Kaliningrad",
67+
"Sudan Standard Time": "Africa/Khartoum",
68+
"Libya Standard Time": "Africa/Tripoli",
69+
"Namibia Standard Time": "Africa/Windhoek",
70+
"Arabic Standard Time": "Asia/Baghdad",
71+
"Turkey Standard Time": "Europe/Istanbul",
72+
"Arab Standard Time": "Asia/Riyadh",
73+
"Belarus Standard Time": "Europe/Minsk",
74+
"Russian Standard Time": "Europe/Moscow",
75+
"E. Africa Standard Time": "Africa/Nairobi",
76+
"Iran Standard Time": "Asia/Tehran",
77+
"Arabian Standard Time": "Asia/Dubai",
78+
"Astrakhan Standard Time": "Europe/Astrakhan",
79+
"Azerbaijan Standard Time": "Asia/Baku",
80+
"Russia Time Zone 3": "Europe/Samara",
81+
"Mauritius Standard Time": "Indian/Mauritius",
82+
"Saratov Standard Time": "Europe/Saratov",
83+
"Georgian Standard Time": "Asia/Tbilisi",
84+
"Volgograd Standard Time": "Europe/Volgograd",
85+
"Caucasus Standard Time": "Asia/Yerevan",
86+
"Afghanistan Standard Time": "Asia/Kabul",
87+
"West Asia Standard Time": "Asia/Tashkent",
88+
"Ekaterinburg Standard Time": "Asia/Yekaterinburg",
89+
"Pakistan Standard Time": "Asia/Karachi",
90+
"India Standard Time": "Asia/Calcutta",
91+
"Sri Lanka Standard Time": "Asia/Colombo",
92+
"Nepal Standard Time": "Asia/Kathmandu",
93+
"Central Asia Standard Time": "Asia/Almaty",
94+
"Bangladesh Standard Time": "Asia/Dhaka",
95+
"Omsk Standard Time": "Asia/Omsk",
96+
"Myanmar Standard Time": "Asia/Rangoon",
97+
"SE Asia Standard Time": "Asia/Bangkok",
98+
"Altai Standard Time": "Asia/Barnaul",
99+
"W. Mongolia Standard Time": "Asia/Hovd",
100+
"North Asia Standard Time": "Asia/Krasnoyarsk",
101+
"N. Central Asia Standard Time": "Asia/Novosibirsk",
102+
"Tomsk Standard Time": "Asia/Tomsk",
103+
"China Standard Time": "Asia/Shanghai",
104+
"North Asia East Standard Time": "Asia/Irkutsk",
105+
"Singapore Standard Time": "Asia/Singapore",
106+
"W. Australia Standard Time": "Australia/Perth",
107+
"Taipei Standard Time": "Asia/Taipei",
108+
"Ulaanbaatar Standard Time": "Asia/Ulaanbaatar",
109+
"North Korea Standard Time": "Asia/Pyongyang",
110+
"Aus Central W. Standard Time": "Australia/Eucla",
111+
"Transbaikal Standard Time": "Asia/Chita",
112+
"Tokyo Standard Time": "Asia/Tokyo",
113+
"Korea Standard Time": "Asia/Seoul",
114+
"Yakutsk Standard Time": "Asia/Yakutsk",
115+
"Cen. Australia Standard Time": "Australia/Adelaide",
116+
"AUS Central Standard Time": "Australia/Darwin",
117+
"E. Australia Standard Time": "Australia/Brisbane",
118+
"AUS Eastern Standard Time": "Australia/Sydney",
119+
"West Pacific Standard Time": "Pacific/Port_Moresby",
120+
"Tasmania Standard Time": "Australia/Hobart",
121+
"Vladivostok Standard Time": "Asia/Vladivostok",
122+
"Lord Howe Standard Time": "Australia/Lord_Howe",
123+
"Bougainville Standard Time": "Pacific/Bougainville",
124+
"Russia Time Zone 10": "Asia/Srednekolymsk",
125+
"Magadan Standard Time": "Asia/Magadan",
126+
"Norfolk Standard Time": "Pacific/Norfolk",
127+
"Sakhalin Standard Time": "Asia/Sakhalin",
128+
"Central Pacific Standard Time": "Pacific/Guadalcanal",
129+
"Russia Time Zone 11": "Asia/Kamchatka",
130+
"New Zealand Standard Time": "Pacific/Auckland",
131+
"UTC+12": "Etc/GMT-12",
132+
"Fiji Standard Time": "Pacific/Fiji",
133+
"Chatham Islands Standard Time": "Pacific/Chatham",
134+
"UTC+13": "Etc/GMT-13",
135+
"Tonga Standard Time": "Pacific/Tongatapu",
136+
"Samoa Standard Time": "Pacific/Apia",
137+
"Line Islands Standard Time": "Pacific/Kiritimati",
138+
}

0 commit comments

Comments
 (0)