Skip to content

Commit 9eefc69

Browse files
committed
feat(outlook): add Microsoft Graph calendar tools
Six calendar operations against graph.microsoft.com/v1.0: list events (calendarView with nextLink paging), get, create, update (partial PATCH), delete, and respond to invites. Shared calendar-utils handles Graph's offset-less dateTime+timeZone shape, attendee normalization, and event flattening. All tools carry the Microsoft Graph error extractor and 429/backoff retry (honors Retry-After) for the mailbox concurrency limit. Registered in the tool registry and barrel.
1 parent 103703e commit 9eefc69

12 files changed

Lines changed: 1513 additions & 0 deletions
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { outlookCalendarCreateEventTool } from '@/tools/outlook/calendar_create_event'
6+
import { outlookCalendarDeleteEventTool } from '@/tools/outlook/calendar_delete_event'
7+
import { outlookCalendarGetEventTool } from '@/tools/outlook/calendar_get_event'
8+
import { outlookCalendarListEventsTool } from '@/tools/outlook/calendar_list_events'
9+
import { outlookCalendarRespondTool } from '@/tools/outlook/calendar_respond'
10+
import { outlookCalendarUpdateEventTool } from '@/tools/outlook/calendar_update_event'
11+
12+
const tools = [
13+
outlookCalendarListEventsTool,
14+
outlookCalendarGetEventTool,
15+
outlookCalendarCreateEventTool,
16+
outlookCalendarUpdateEventTool,
17+
outlookCalendarDeleteEventTool,
18+
outlookCalendarRespondTool,
19+
]
20+
21+
describe('outlook calendar tools', () => {
22+
it('every tool uses the outlook oauth provider and a snake_case id', () => {
23+
for (const tool of tools) {
24+
expect(tool.oauth).toEqual({ required: true, provider: 'outlook' })
25+
expect(tool.id).toMatch(/^outlook_calendar_[a-z_]+$/)
26+
expect(tool.version).toBe('1.0.0')
27+
expect(tool.outputs?.message).toBeDefined()
28+
}
29+
})
30+
31+
it('exposes the expected tool ids', () => {
32+
expect(tools.map((t) => t.id)).toEqual([
33+
'outlook_calendar_list_events',
34+
'outlook_calendar_get_event',
35+
'outlook_calendar_create_event',
36+
'outlook_calendar_update_event',
37+
'outlook_calendar_delete_event',
38+
'outlook_calendar_respond',
39+
])
40+
})
41+
42+
it('builds a calendarView URL with the time window and paging', () => {
43+
const url = outlookCalendarListEventsTool.request.url as (p: unknown) => string
44+
const built = url({
45+
accessToken: 't',
46+
startDateTime: '2025-06-03T00:00:00Z',
47+
endDateTime: '2025-06-10T00:00:00Z',
48+
maxResults: 5,
49+
})
50+
expect(built).toContain('https://graph.microsoft.com/v1.0/me/calendarView')
51+
expect(built).toContain('startDateTime=2025-06-03T00%3A00%3A00Z')
52+
expect(built).toContain('%24top=5')
53+
expect(built).toContain('%24orderby=start%2FdateTime')
54+
55+
// A nextLink page token is used verbatim.
56+
expect(url({ accessToken: 't', pageToken: 'https://graph.microsoft.com/next' })).toBe(
57+
'https://graph.microsoft.com/next'
58+
)
59+
})
60+
61+
it('builds a create-event body with Graph datetime shape and attendees', () => {
62+
const body = outlookCalendarCreateEventTool.request.body as (
63+
p: unknown
64+
) => Record<string, unknown>
65+
const built = body({
66+
accessToken: 't',
67+
subject: 'Sync',
68+
startDateTime: '2025-06-03T10:00:00-08:00',
69+
endDateTime: '2025-06-03T11:00:00-08:00',
70+
attendees: 'a@x.com, b@y.com',
71+
isOnlineMeeting: true,
72+
})
73+
expect(built.subject).toBe('Sync')
74+
expect(built.start).toEqual({ dateTime: '2025-06-03T18:00:00', timeZone: 'UTC' })
75+
expect(built.attendees).toHaveLength(2)
76+
expect(built.isOnlineMeeting).toBe(true)
77+
// We intentionally do NOT set onlineMeetingProvider so Graph uses the mailbox
78+
// default (Teams on work/school accounts) instead of hardcoding a work-only value.
79+
expect('onlineMeetingProvider' in built).toBe(false)
80+
})
81+
82+
it('rejects an invalid respond responseType', () => {
83+
const url = outlookCalendarRespondTool.request.url as (p: unknown) => string
84+
expect(() => url({ accessToken: 't', eventId: 'e1', responseType: 'maybe' })).toThrow(
85+
/Invalid responseType/
86+
)
87+
expect(url({ accessToken: 't', eventId: 'e1', responseType: 'accept' })).toBe(
88+
'https://graph.microsoft.com/v1.0/me/events/e1/accept'
89+
)
90+
})
91+
92+
it('omits the comment key when empty so sendResponse=false is accepted by Graph', () => {
93+
const body = outlookCalendarRespondTool.request.body as (p: unknown) => Record<string, unknown>
94+
// Empty/whitespace comment with sendResponse off: comment must NOT be present.
95+
const noComment = body({
96+
eventId: 'e1',
97+
responseType: 'accept',
98+
sendResponse: false,
99+
comment: '',
100+
})
101+
expect(noComment).toEqual({ sendResponse: false })
102+
expect('comment' in noComment).toBe(false)
103+
104+
const nullComment = body({ eventId: 'e1', responseType: 'decline', sendResponse: false })
105+
expect('comment' in nullComment).toBe(false)
106+
107+
// A real comment is included, and sendResponse defaults to true.
108+
const withComment = body({ eventId: 'e1', responseType: 'accept', comment: 'See you there' })
109+
expect(withComment).toEqual({ sendResponse: true, comment: 'See you there' })
110+
})
111+
112+
it('enables retry with backoff on every calendar tool (429/mailbox concurrency)', () => {
113+
for (const tool of tools) {
114+
expect(tool.request.retry?.enabled).toBe(true)
115+
// false so POST/PATCH (create/update/respond) also retry on a 429 throttle.
116+
expect(tool.request.retry?.retryIdempotentOnly).toBe(false)
117+
}
118+
})
119+
})
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
buildGraphEventDateTime,
7+
DEFAULT_OUTLOOK_TIME_ZONE,
8+
flattenGraphEvent,
9+
normalizeAttendees,
10+
} from '@/tools/outlook/calendar-utils'
11+
import type { GraphEvent } from '@/tools/outlook/types'
12+
13+
describe('buildGraphEventDateTime', () => {
14+
it('treats a date-only value as an all-day midnight start in the given zone', () => {
15+
expect(buildGraphEventDateTime('2025-06-03', 'America/Los_Angeles')).toEqual({
16+
dateTime: '2025-06-03T00:00:00',
17+
timeZone: 'America/Los_Angeles',
18+
})
19+
})
20+
21+
it('defaults the zone to UTC when none is given for a date-only value', () => {
22+
expect(buildGraphEventDateTime('2025-06-03')).toEqual({
23+
dateTime: '2025-06-03T00:00:00',
24+
timeZone: DEFAULT_OUTLOOK_TIME_ZONE,
25+
})
26+
})
27+
28+
it('keeps a naive datetime as wall-clock time in the provided zone', () => {
29+
expect(buildGraphEventDateTime('2025-06-03T10:00:00', 'America/New_York')).toEqual({
30+
dateTime: '2025-06-03T10:00:00',
31+
timeZone: 'America/New_York',
32+
})
33+
})
34+
35+
it('converts a Z-suffixed datetime to an offset-less UTC value', () => {
36+
expect(buildGraphEventDateTime('2025-06-03T10:00:00Z')).toEqual({
37+
dateTime: '2025-06-03T10:00:00',
38+
timeZone: 'UTC',
39+
})
40+
})
41+
42+
it('converts an offset datetime to the equivalent UTC instant', () => {
43+
// 10:00 at -08:00 is 18:00 UTC
44+
expect(buildGraphEventDateTime('2025-06-03T10:00:00-08:00')).toEqual({
45+
dateTime: '2025-06-03T18:00:00',
46+
timeZone: 'UTC',
47+
})
48+
})
49+
})
50+
51+
describe('normalizeAttendees', () => {
52+
it('returns an empty array for undefined', () => {
53+
expect(normalizeAttendees(undefined)).toEqual([])
54+
})
55+
56+
it('splits a comma-separated string into required attendees', () => {
57+
expect(normalizeAttendees('a@x.com, b@y.com')).toEqual([
58+
{ emailAddress: { address: 'a@x.com' }, type: 'required' },
59+
{ emailAddress: { address: 'b@y.com' }, type: 'required' },
60+
])
61+
})
62+
63+
it('accepts an array and honors a custom type', () => {
64+
expect(normalizeAttendees(['a@x.com'], 'optional')).toEqual([
65+
{ emailAddress: { address: 'a@x.com' }, type: 'optional' },
66+
])
67+
})
68+
69+
it('drops empty entries', () => {
70+
expect(normalizeAttendees('a@x.com,,\n , b@y.com')).toEqual([
71+
{ emailAddress: { address: 'a@x.com' }, type: 'required' },
72+
{ emailAddress: { address: 'b@y.com' }, type: 'required' },
73+
])
74+
})
75+
})
76+
77+
describe('flattenGraphEvent', () => {
78+
it('flattens the Graph event into editor-friendly fields', () => {
79+
const event: GraphEvent = {
80+
id: 'evt-1',
81+
subject: 'Standup',
82+
bodyPreview: 'Daily sync',
83+
start: { dateTime: '2025-06-03T10:00:00.0000000', timeZone: 'UTC' },
84+
end: { dateTime: '2025-06-03T10:30:00.0000000', timeZone: 'UTC' },
85+
isAllDay: false,
86+
webLink: 'https://outlook.office.com/evt-1',
87+
location: { displayName: 'Room 1' },
88+
organizer: { emailAddress: { name: 'Alice', address: 'alice@x.com' } },
89+
onlineMeeting: { joinUrl: 'https://teams.example/join' },
90+
attendees: [
91+
{
92+
type: 'required',
93+
status: { response: 'accepted' },
94+
emailAddress: { name: 'Bob', address: 'bob@y.com' },
95+
},
96+
],
97+
}
98+
99+
expect(flattenGraphEvent(event)).toEqual({
100+
id: 'evt-1',
101+
subject: 'Standup',
102+
bodyPreview: 'Daily sync',
103+
start: { dateTime: '2025-06-03T10:00:00.0000000', timeZone: 'UTC' },
104+
end: { dateTime: '2025-06-03T10:30:00.0000000', timeZone: 'UTC' },
105+
isAllDay: false,
106+
location: 'Room 1',
107+
organizer: { name: 'Alice', address: 'alice@x.com' },
108+
attendees: [{ name: 'Bob', address: 'bob@y.com', type: 'required', response: 'accepted' }],
109+
onlineMeeting: { joinUrl: 'https://teams.example/join' },
110+
webLink: 'https://outlook.office.com/evt-1',
111+
})
112+
})
113+
114+
it('returns a null onlineMeeting when the event has no join URL', () => {
115+
const event: GraphEvent = { id: 'evt-2' }
116+
const flattened = flattenGraphEvent(event)
117+
expect(flattened.onlineMeeting).toBeNull()
118+
expect(flattened.attendees).toEqual([])
119+
})
120+
})
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type {
2+
CleanedOutlookEvent,
3+
CleanedOutlookEventAttendee,
4+
GraphAttendee,
5+
GraphDateTimeTimeZone,
6+
GraphEvent,
7+
} from '@/tools/outlook/types'
8+
import type { ToolRetryConfig } from '@/tools/types'
9+
10+
/**
11+
* Shared retry config for the Outlook calendar tools.
12+
*
13+
* Microsoft Graph throttles a single mailbox at roughly four concurrent requests and
14+
* returns 429 with a `Retry-After` header when calendar operations fan out (e.g. many
15+
* parallel event creates in a workflow). The tool executor retries 429/5xx with backoff
16+
* and honors `Retry-After`. `retryIdempotentOnly` is `false` so create/update/respond
17+
* (POST/PATCH) also retry — a 429 is a pre-processing throttle, so retrying does not
18+
* duplicate the event.
19+
*/
20+
export const CALENDAR_RETRY: ToolRetryConfig = {
21+
enabled: true,
22+
maxRetries: 3,
23+
initialDelayMs: 500,
24+
maxDelayMs: 30000,
25+
retryIdempotentOnly: false,
26+
}
27+
28+
/** Matches a trailing UTC offset (`Z`, `+02:00`, `-0800`) on an ISO datetime string. */
29+
const TZ_OFFSET_PATTERN = /([+-]\d{2}:?\d{2}|Z)$/
30+
31+
/** Matches the milliseconds + `Z` suffix produced by `Date.toISOString()`. */
32+
const ISO_MS_ZULU_PATTERN = /\.\d{3}Z$/
33+
34+
/** Time zone recorded when the caller supplies none and the datetime carries no offset. */
35+
export const DEFAULT_OUTLOOK_TIME_ZONE = 'UTC'
36+
37+
/** Attendee category understood by Microsoft Graph. */
38+
export type GraphAttendeeType = 'required' | 'optional' | 'resource'
39+
40+
/** Attendee payload shape Microsoft Graph expects on event create/update. */
41+
export interface GraphAttendeeInput {
42+
emailAddress: { address: string }
43+
type: GraphAttendeeType
44+
}
45+
46+
/**
47+
* Build a Microsoft Graph `{ dateTime, timeZone }` value from a user-supplied datetime.
48+
*
49+
* Graph expects an offset-less `dateTime` paired with a named `timeZone`, unlike Google
50+
* Calendar which accepts the offset inside the string. Behavior:
51+
* - Date-only input (`2025-06-03`) is pinned to midnight for an all-day event.
52+
* - An input carrying a UTC offset (`...Z` or `+02:00`) is converted to the equivalent UTC
53+
* instant so Graph interprets it unambiguously.
54+
* - A naive datetime is treated as wall-clock time in the provided (or default) zone.
55+
*/
56+
export function buildGraphEventDateTime(value: string, timeZone?: string): GraphDateTimeTimeZone {
57+
const trimmed = value.trim()
58+
59+
if (!trimmed.includes('T')) {
60+
return { dateTime: `${trimmed}T00:00:00`, timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE }
61+
}
62+
63+
const offsetMatch = trimmed.match(TZ_OFFSET_PATTERN)
64+
if (offsetMatch) {
65+
const parsed = new Date(trimmed)
66+
if (!Number.isNaN(parsed.getTime())) {
67+
return { dateTime: parsed.toISOString().replace(ISO_MS_ZULU_PATTERN, ''), timeZone: 'UTC' }
68+
}
69+
// Unparseable offset: strip it and fall back to the caller's zone.
70+
return {
71+
dateTime: trimmed.slice(0, trimmed.length - offsetMatch[0].length),
72+
timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE,
73+
}
74+
}
75+
76+
return { dateTime: trimmed, timeZone: timeZone || DEFAULT_OUTLOOK_TIME_ZONE }
77+
}
78+
79+
/**
80+
* Normalize a comma/newline-separated string (or array) of email addresses into the Graph
81+
* attendee payload shape.
82+
*/
83+
export function normalizeAttendees(
84+
attendees: string | string[] | undefined,
85+
type: GraphAttendeeType = 'required'
86+
): GraphAttendeeInput[] {
87+
if (!attendees) return []
88+
89+
const list = Array.isArray(attendees) ? attendees : String(attendees).split(/[,\n]/)
90+
91+
return list
92+
.map((address) => address.trim())
93+
.filter(Boolean)
94+
.map((address) => ({ emailAddress: { address }, type }))
95+
}
96+
97+
/** Flatten a raw Graph attendee into our cleaned attendee shape. */
98+
function flattenGraphAttendee(attendee: GraphAttendee): CleanedOutlookEventAttendee {
99+
return {
100+
name: attendee.emailAddress?.name,
101+
address: attendee.emailAddress?.address,
102+
type: attendee.type,
103+
response: attendee.status?.response,
104+
}
105+
}
106+
107+
/**
108+
* Flatten a raw Microsoft Graph event into the cleaned, editor-friendly event shape our
109+
* tools return: `id, subject, start, end, organizer, attendees, isAllDay, onlineMeeting,
110+
* webLink, bodyPreview`.
111+
*/
112+
export function flattenGraphEvent(event: GraphEvent): CleanedOutlookEvent {
113+
return {
114+
id: event.id,
115+
subject: event.subject,
116+
bodyPreview: event.bodyPreview,
117+
start: event.start
118+
? { dateTime: event.start.dateTime, timeZone: event.start.timeZone }
119+
: undefined,
120+
end: event.end ? { dateTime: event.end.dateTime, timeZone: event.end.timeZone } : undefined,
121+
isAllDay: event.isAllDay,
122+
location: event.location?.displayName,
123+
organizer: event.organizer?.emailAddress
124+
? {
125+
name: event.organizer.emailAddress.name,
126+
address: event.organizer.emailAddress.address,
127+
}
128+
: undefined,
129+
attendees: (event.attendees ?? []).map(flattenGraphAttendee),
130+
onlineMeeting: event.onlineMeeting?.joinUrl ? { joinUrl: event.onlineMeeting.joinUrl } : null,
131+
webLink: event.webLink,
132+
}
133+
}

0 commit comments

Comments
 (0)