@agent-native/scheduling 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +16 -0
  2. package/agent-native.package.json +154 -0
  3. package/dist/manifest.d.ts +5 -1
  4. package/dist/manifest.d.ts.map +1 -1
  5. package/dist/manifest.js +6 -1
  6. package/dist/manifest.js.map +1 -1
  7. package/dist/server/availability-engine.d.ts +20 -0
  8. package/dist/server/availability-engine.d.ts.map +1 -1
  9. package/dist/server/availability-engine.js +73 -23
  10. package/dist/server/availability-engine.js.map +1 -1
  11. package/dist/server/booking-service.d.ts.map +1 -1
  12. package/dist/server/booking-service.js +16 -5
  13. package/dist/server/booking-service.js.map +1 -1
  14. package/dist/server/bookings-repo.d.ts.map +1 -1
  15. package/dist/server/bookings-repo.js +50 -48
  16. package/dist/server/bookings-repo.js.map +1 -1
  17. package/dist/server/providers/index.d.ts +2 -0
  18. package/dist/server/providers/index.d.ts.map +1 -1
  19. package/dist/server/providers/index.js +1 -0
  20. package/dist/server/providers/index.js.map +1 -1
  21. package/dist/server/providers/teams.d.ts +22 -0
  22. package/dist/server/providers/teams.d.ts.map +1 -0
  23. package/dist/server/providers/teams.js +112 -0
  24. package/dist/server/providers/teams.js.map +1 -0
  25. package/docs/eject.md +11 -10
  26. package/docs/llms-full.txt +75 -14
  27. package/docs/llms.txt +15 -1
  28. package/docs/providers.md +30 -2
  29. package/docs/skills/integrations/SKILL.md +19 -1
  30. package/package.json +7 -6
  31. package/src/manifest.test.ts +19 -0
  32. package/src/manifest.ts +9 -7
  33. package/src/server/availability-engine.test.ts +344 -0
  34. package/src/server/availability-engine.ts +100 -22
  35. package/src/server/booking-service.spec.ts +557 -0
  36. package/src/server/booking-service.ts +17 -6
  37. package/src/server/bookings-repo.ts +54 -48
  38. package/src/server/providers/index.ts +2 -0
  39. package/src/server/providers/teams.test.ts +246 -0
  40. package/src/server/providers/teams.ts +183 -0
@@ -225,56 +225,62 @@ export async function insertBooking(
225
225
  const id = nanoid();
226
226
  const uid = nanoid(12);
227
227
  const iCalUid = input.iCalUid ?? `${uid}@agent-native-scheduling`;
228
- await db.insert(schema.bookings).values({
229
- id,
230
- uid,
231
- eventTypeId: input.eventTypeId,
232
- hostEmail: input.hostEmail,
233
- title: input.title,
234
- description: input.description ?? null,
235
- startTime: input.startTime,
236
- endTime: input.endTime,
237
- timezone: input.timezone,
238
- status: input.status ?? "confirmed",
239
- location: input.location ? JSON.stringify(input.location) : null,
240
- customResponses: input.customResponses
241
- ? JSON.stringify(input.customResponses)
242
- : null,
243
- cancelToken: nanoid(24),
244
- rescheduleToken: nanoid(24),
245
- fromReschedule: input.fromReschedule ?? null,
246
- iCalUid,
247
- iCalSequence: input.iCalSequence ?? 0,
248
- paid: false,
249
- createdAt: now,
250
- updatedAt: now,
251
- ownerEmail: input.ownerEmail,
252
- orgId: input.orgId ?? null,
253
- });
254
- for (const a of input.attendees) {
255
- await db.insert(schema.bookingAttendees).values({
256
- id: nanoid(),
257
- bookingId: id,
258
- email: a.email,
259
- name: a.name,
260
- timezone: a.timezone ?? null,
261
- locale: a.locale ?? null,
262
- noShow: false,
263
- createdAt: now,
264
- });
265
- }
266
- for (const r of input.references ?? []) {
267
- await db.insert(schema.bookingReferences).values({
268
- id: nanoid(),
269
- bookingId: id,
270
- type: r.type,
271
- externalId: r.externalId,
272
- meetingUrl: r.meetingUrl ?? null,
273
- meetingPassword: r.meetingPassword ?? null,
274
- credentialId: r.credentialId ?? null,
228
+ await db.transaction(async (tx: any) => {
229
+ await tx.insert(schema.bookings).values({
230
+ id,
231
+ uid,
232
+ eventTypeId: input.eventTypeId,
233
+ hostEmail: input.hostEmail,
234
+ title: input.title,
235
+ description: input.description ?? null,
236
+ startTime: input.startTime,
237
+ endTime: input.endTime,
238
+ timezone: input.timezone,
239
+ status: input.status ?? "confirmed",
240
+ location: input.location ? JSON.stringify(input.location) : null,
241
+ customResponses: input.customResponses
242
+ ? JSON.stringify(input.customResponses)
243
+ : null,
244
+ cancelToken: nanoid(24),
245
+ rescheduleToken: nanoid(24),
246
+ fromReschedule: input.fromReschedule ?? null,
247
+ iCalUid,
248
+ iCalSequence: input.iCalSequence ?? 0,
249
+ paid: false,
275
250
  createdAt: now,
251
+ updatedAt: now,
252
+ ownerEmail: input.ownerEmail,
253
+ orgId: input.orgId ?? null,
276
254
  });
277
- }
255
+ if (input.attendees.length > 0) {
256
+ await tx.insert(schema.bookingAttendees).values(
257
+ input.attendees.map((a) => ({
258
+ id: nanoid(),
259
+ bookingId: id,
260
+ email: a.email,
261
+ name: a.name,
262
+ timezone: a.timezone ?? null,
263
+ locale: a.locale ?? null,
264
+ noShow: false,
265
+ createdAt: now,
266
+ })),
267
+ );
268
+ }
269
+ if (input.references && input.references.length > 0) {
270
+ await tx.insert(schema.bookingReferences).values(
271
+ input.references.map((r) => ({
272
+ id: nanoid(),
273
+ bookingId: id,
274
+ type: r.type,
275
+ externalId: r.externalId,
276
+ meetingUrl: r.meetingUrl ?? null,
277
+ meetingPassword: r.meetingPassword ?? null,
278
+ credentialId: r.credentialId ?? null,
279
+ createdAt: now,
280
+ })),
281
+ );
282
+ }
283
+ });
278
284
  const created = await getBookingByUid(uid);
279
285
  if (!created) throw new Error("Failed to create booking");
280
286
  return created;
@@ -3,5 +3,7 @@ export * from "./registry.js";
3
3
  export { createGoogleCalendarProvider } from "./google-calendar.js";
4
4
  export { createOffice365Provider } from "./office365.js";
5
5
  export { createZoomProvider } from "./zoom.js";
6
+ export { createTeamsProvider } from "./teams.js";
7
+ export type { TeamsProviderConfig } from "./teams.js";
6
8
  export { createDailyVideoProvider } from "./builtin-video.js";
7
9
  export { googleMeetProvider } from "./google-meet.js";
@@ -0,0 +1,246 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type { Booking } from "../../shared/index.js";
4
+ import { createTeamsProvider } from "./teams.js";
5
+
6
+ const booking: Booking = {
7
+ id: "booking-example",
8
+ uid: "booking-uid-example",
9
+ eventTypeId: "event-type-example",
10
+ hostEmail: "host@example.com",
11
+ title: "Example planning call",
12
+ startTime: "2026-07-15T17:00:00.000Z",
13
+ endTime: "2026-07-15T17:30:00.000Z",
14
+ timezone: "UTC",
15
+ status: "confirmed",
16
+ attendees: [{ email: "guest@example.com", name: "Example Guest" }],
17
+ references: [],
18
+ iCalUid: "ical-example",
19
+ iCalSequence: 0,
20
+ createdAt: "2026-07-10T00:00:00.000Z",
21
+ updatedAt: "2026-07-10T00:00:00.000Z",
22
+ };
23
+
24
+ describe("createTeamsProvider", () => {
25
+ const fetchMock = vi.fn<typeof fetch>();
26
+ const getAccessToken = vi.fn(async () => "access-token-example");
27
+ const updateTokens = vi.fn();
28
+ const markInvalid = vi.fn();
29
+
30
+ beforeEach(() => {
31
+ vi.stubGlobal("fetch", fetchMock);
32
+ vi.useFakeTimers();
33
+ vi.setSystemTime(new Date("2026-07-10T00:00:00.000Z"));
34
+ });
35
+
36
+ afterEach(() => {
37
+ vi.useRealTimers();
38
+ vi.unstubAllGlobals();
39
+ vi.clearAllMocks();
40
+ });
41
+
42
+ function provider(tenant?: string) {
43
+ return createTeamsProvider({
44
+ clientId: "client-id-example",
45
+ clientSecret: "client-secret-example",
46
+ tenant,
47
+ getAccessToken,
48
+ updateTokens,
49
+ markInvalid,
50
+ });
51
+ }
52
+
53
+ it("uses the Teams kind and a work-or-school OAuth default", async () => {
54
+ const teams = provider();
55
+ expect(teams).toMatchObject({
56
+ kind: "teams_video",
57
+ label: "Microsoft Teams",
58
+ });
59
+ const result = await teams.startOAuth!({
60
+ redirectUri: "https://calendar.example.com/oauth/callback",
61
+ state: "state-example",
62
+ });
63
+ const url = new URL(result.authUrl);
64
+ expect(url.pathname).toBe("/organizations/oauth2/v2.0/authorize");
65
+ expect(url.searchParams.get("client_id")).toBe("client-id-example");
66
+ expect(url.searchParams.get("redirect_uri")).toBe(
67
+ "https://calendar.example.com/oauth/callback",
68
+ );
69
+ expect(url.searchParams.get("state")).toBe("state-example");
70
+ expect(url.searchParams.get("response_type")).toBe("code");
71
+ expect(url.searchParams.get("response_mode")).toBe("query");
72
+ expect(url.searchParams.get("scope")?.split(" ")).toEqual([
73
+ "offline_access",
74
+ "OnlineMeetings.ReadWrite",
75
+ "User.Read",
76
+ ]);
77
+ expect(result.authUrl).not.toContain("client-secret-example");
78
+ });
79
+
80
+ it("uses an explicit Microsoft tenant", async () => {
81
+ const result = await provider("tenant-example").startOAuth!({
82
+ redirectUri: "https://calendar.example.com/oauth/callback",
83
+ state: "state-example",
84
+ });
85
+ expect(new URL(result.authUrl).pathname).toBe(
86
+ "/tenant-example/oauth2/v2.0/authorize",
87
+ );
88
+ });
89
+
90
+ it("exchanges OAuth tokens and resolves the Microsoft identity", async () => {
91
+ fetchMock
92
+ .mockResolvedValueOnce(
93
+ new Response(
94
+ JSON.stringify({
95
+ access_token: "new-access-token-example",
96
+ refresh_token: "refresh-token-example",
97
+ expires_in: 1800,
98
+ }),
99
+ { status: 200 },
100
+ ),
101
+ )
102
+ .mockResolvedValueOnce(
103
+ new Response(
104
+ JSON.stringify({
105
+ id: "microsoft-user-example",
106
+ mail: "microsoft-user@example.com",
107
+ displayName: "Example User",
108
+ }),
109
+ { status: 200 },
110
+ ),
111
+ );
112
+
113
+ const result = await provider("tenant-example").completeOAuth!({
114
+ credentialId: "credential-example",
115
+ userEmail: "fallback@example.com",
116
+ code: "authorization-code-example",
117
+ redirectUri: "https://calendar.example.com/oauth/callback",
118
+ });
119
+
120
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
121
+ "https://login.microsoftonline.com/tenant-example/oauth2/v2.0/token",
122
+ );
123
+ const tokenInit = fetchMock.mock.calls[0]?.[1];
124
+ expect(tokenInit?.method).toBe("POST");
125
+ const tokenBody = tokenInit?.body as URLSearchParams;
126
+ expect(tokenBody.get("grant_type")).toBe("authorization_code");
127
+ expect(tokenBody.get("code")).toBe("authorization-code-example");
128
+ expect(updateTokens).toHaveBeenCalledWith(
129
+ "credential-example",
130
+ expect.objectContaining({
131
+ accessToken: "new-access-token-example",
132
+ refreshToken: "refresh-token-example",
133
+ expiresAt: new Date("2026-07-10T00:30:00.000Z"),
134
+ }),
135
+ );
136
+ expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
137
+ headers: { authorization: "Bearer new-access-token-example" },
138
+ });
139
+ expect(result).toEqual({
140
+ externalAccountId: "microsoft-user-example",
141
+ externalEmail: "microsoft-user@example.com",
142
+ displayName: "Example User",
143
+ });
144
+ });
145
+
146
+ it("does not persist tokens after a failed exchange", async () => {
147
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 400 }));
148
+ await expect(
149
+ provider().completeOAuth!({
150
+ credentialId: "credential-example",
151
+ userEmail: "user@example.com",
152
+ code: "authorization-code-example",
153
+ redirectUri: "https://calendar.example.com/oauth/callback",
154
+ }),
155
+ ).rejects.toThrow("token exchange failed (400)");
156
+ expect(updateTokens).not.toHaveBeenCalled();
157
+ expect(fetchMock).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ it("creates a Teams meeting and maps id plus joinWebUrl", async () => {
161
+ fetchMock.mockResolvedValueOnce(
162
+ new Response(
163
+ JSON.stringify({
164
+ id: "meeting/id example",
165
+ joinWebUrl: "https://teams.microsoft.com/l/meetup-join/example",
166
+ }),
167
+ { status: 201 },
168
+ ),
169
+ );
170
+ const result = await provider().createMeeting({
171
+ credentialId: "credential-example",
172
+ booking,
173
+ });
174
+ expect(getAccessToken).toHaveBeenCalledWith("credential-example");
175
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
176
+ "https://graph.microsoft.com/v1.0/me/onlineMeetings",
177
+ );
178
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
179
+ expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
180
+ subject: booking.title,
181
+ startDateTime: booking.startTime,
182
+ endDateTime: booking.endTime,
183
+ });
184
+ expect(result).toEqual({
185
+ meetingId: "meeting/id example",
186
+ meetingUrl: "https://teams.microsoft.com/l/meetup-join/example",
187
+ });
188
+ });
189
+
190
+ it("requires a credential before creating a meeting", async () => {
191
+ await expect(provider().createMeeting({ booking })).rejects.toThrow(
192
+ "requires credentialId",
193
+ );
194
+ expect(fetchMock).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it.each([401, 403])(
198
+ "marks a credential invalid on create HTTP %s",
199
+ async (status) => {
200
+ fetchMock.mockResolvedValueOnce(new Response(null, { status }));
201
+ await expect(
202
+ provider().createMeeting({
203
+ credentialId: "credential-example",
204
+ booking,
205
+ }),
206
+ ).rejects.toThrow(`creation failed (${status})`);
207
+ expect(markInvalid).toHaveBeenCalledWith("credential-example");
208
+ },
209
+ );
210
+
211
+ it("deletes a meeting using an encoded opaque id", async () => {
212
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
213
+ await provider().deleteMeeting!({
214
+ credentialId: "credential-example",
215
+ meetingId: "meeting/id example",
216
+ });
217
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
218
+ "https://graph.microsoft.com/v1.0/me/onlineMeetings/meeting%2Fid%20example",
219
+ );
220
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: "DELETE" });
221
+ });
222
+
223
+ it("treats an already-deleted meeting as success", async () => {
224
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 }));
225
+ await expect(
226
+ provider().deleteMeeting!({
227
+ credentialId: "credential-example",
228
+ meetingId: "missing-meeting-example",
229
+ }),
230
+ ).resolves.toBeUndefined();
231
+ });
232
+
233
+ it.each([401, 403])(
234
+ "marks a credential invalid on delete HTTP %s",
235
+ async (status) => {
236
+ fetchMock.mockResolvedValueOnce(new Response(null, { status }));
237
+ await expect(
238
+ provider().deleteMeeting!({
239
+ credentialId: "credential-example",
240
+ meetingId: "meeting-example",
241
+ }),
242
+ ).rejects.toThrow(`deletion failed (${status})`);
243
+ expect(markInvalid).toHaveBeenCalledWith("credential-example");
244
+ },
245
+ );
246
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Microsoft Teams provider — delegated Microsoft OAuth with Graph-backed
3
+ * standalone online meetings.
4
+ */
5
+ import type { VideoProvider } from "./types.js";
6
+
7
+ export interface TeamsProviderConfig {
8
+ clientId: string;
9
+ clientSecret: string;
10
+ /** Microsoft tenant id/domain; defaults to work-or-school accounts. */
11
+ tenant?: string;
12
+ getAccessToken: (credentialId: string) => Promise<string>;
13
+ updateTokens?: (
14
+ credentialId: string,
15
+ tokens: {
16
+ accessToken: string;
17
+ refreshToken?: string;
18
+ expiresAt?: Date;
19
+ rawResponse?: Record<string, unknown>;
20
+ },
21
+ ) => Promise<void>;
22
+ /** Called when Graph returns 401/403; mark the credential invalid in UI. */
23
+ markInvalid?: (credentialId: string) => Promise<void>;
24
+ }
25
+
26
+ const SCOPES = ["offline_access", "OnlineMeetings.ReadWrite", "User.Read"];
27
+ const GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0";
28
+
29
+ export function createTeamsProvider(
30
+ config: TeamsProviderConfig,
31
+ ): VideoProvider {
32
+ const tenant = config.tenant ?? "organizations";
33
+ const oauthBaseUrl = `https://login.microsoftonline.com/${encodeURIComponent(tenant)}/oauth2/v2.0`;
34
+
35
+ async function graphRequest(
36
+ credentialId: string,
37
+ path: string,
38
+ init?: RequestInit,
39
+ ): Promise<Response> {
40
+ const token = await config.getAccessToken(credentialId);
41
+ const response = await fetch(`${GRAPH_BASE_URL}${path}`, {
42
+ ...init,
43
+ headers: {
44
+ ...(init?.headers ?? {}),
45
+ authorization: `Bearer ${token}`,
46
+ "content-type": "application/json",
47
+ },
48
+ });
49
+ if (response.status === 401 || response.status === 403) {
50
+ await config.markInvalid?.(credentialId);
51
+ }
52
+ return response;
53
+ }
54
+
55
+ return {
56
+ kind: "teams_video",
57
+ label: "Microsoft Teams",
58
+
59
+ async startOAuth({ redirectUri, state }) {
60
+ const params = new URLSearchParams({
61
+ client_id: config.clientId,
62
+ redirect_uri: redirectUri,
63
+ response_type: "code",
64
+ response_mode: "query",
65
+ scope: SCOPES.join(" "),
66
+ prompt: "consent",
67
+ state,
68
+ });
69
+ return { authUrl: `${oauthBaseUrl}/authorize?${params}` };
70
+ },
71
+
72
+ async completeOAuth({ code, redirectUri, credentialId, userEmail }) {
73
+ const requestBody = new URLSearchParams({
74
+ client_id: config.clientId,
75
+ client_secret: config.clientSecret,
76
+ code,
77
+ redirect_uri: redirectUri,
78
+ grant_type: "authorization_code",
79
+ scope: SCOPES.join(" "),
80
+ });
81
+ const tokenResponse = await fetch(`${oauthBaseUrl}/token`, {
82
+ method: "POST",
83
+ headers: { "content-type": "application/x-www-form-urlencoded" },
84
+ body: requestBody,
85
+ });
86
+ if (!tokenResponse.ok) {
87
+ throw new Error(
88
+ `Microsoft Teams token exchange failed (${tokenResponse.status})`,
89
+ );
90
+ }
91
+ const tokens = (await tokenResponse.json()) as {
92
+ access_token?: string;
93
+ refresh_token?: string;
94
+ expires_in?: number;
95
+ [key: string]: unknown;
96
+ };
97
+ if (!tokens.access_token) {
98
+ throw new Error(
99
+ "Microsoft Teams token exchange returned no access token",
100
+ );
101
+ }
102
+
103
+ await config.updateTokens?.(credentialId, {
104
+ accessToken: tokens.access_token,
105
+ refreshToken: tokens.refresh_token,
106
+ expiresAt: new Date(Date.now() + (tokens.expires_in ?? 3600) * 1000),
107
+ rawResponse: tokens,
108
+ });
109
+
110
+ // Use the token from this exchange directly. The consumer's token store
111
+ // may not be observable through getAccessToken until after this callback.
112
+ const identityResponse = await fetch(
113
+ `${GRAPH_BASE_URL}/me?$select=id,mail,userPrincipalName,displayName`,
114
+ { headers: { authorization: `Bearer ${tokens.access_token}` } },
115
+ );
116
+ if (!identityResponse.ok) {
117
+ throw new Error(
118
+ `Microsoft Teams identity lookup failed (${identityResponse.status})`,
119
+ );
120
+ }
121
+ const identity = (await identityResponse.json()) as {
122
+ id?: string;
123
+ mail?: string;
124
+ userPrincipalName?: string;
125
+ displayName?: string;
126
+ };
127
+ if (!identity.id) {
128
+ throw new Error(
129
+ "Microsoft Teams identity lookup returned no account id",
130
+ );
131
+ }
132
+ return {
133
+ externalAccountId: identity.id,
134
+ externalEmail: identity.mail ?? identity.userPrincipalName ?? userEmail,
135
+ displayName: identity.displayName,
136
+ };
137
+ },
138
+
139
+ async createMeeting({ credentialId, booking }) {
140
+ if (!credentialId) {
141
+ throw new Error("Microsoft Teams requires credentialId");
142
+ }
143
+ const response = await graphRequest(credentialId, "/me/onlineMeetings", {
144
+ method: "POST",
145
+ body: JSON.stringify({
146
+ subject: booking.title,
147
+ startDateTime: booking.startTime,
148
+ endDateTime: booking.endTime,
149
+ }),
150
+ });
151
+ if (!response.ok) {
152
+ throw new Error(
153
+ `Microsoft Teams meeting creation failed (${response.status})`,
154
+ );
155
+ }
156
+ const meeting = (await response.json()) as {
157
+ id?: string;
158
+ joinWebUrl?: string;
159
+ };
160
+ if (!meeting.id || !meeting.joinWebUrl) {
161
+ throw new Error(
162
+ "Microsoft Teams meeting response is missing id or joinWebUrl",
163
+ );
164
+ }
165
+ return { meetingId: meeting.id, meetingUrl: meeting.joinWebUrl };
166
+ },
167
+
168
+ async deleteMeeting({ credentialId, meetingId }) {
169
+ if (!credentialId) return;
170
+ const response = await graphRequest(
171
+ credentialId,
172
+ `/me/onlineMeetings/${encodeURIComponent(meetingId)}`,
173
+ { method: "DELETE" },
174
+ );
175
+ if (response.status === 204 || response.status === 404) return;
176
+ if (!response.ok) {
177
+ throw new Error(
178
+ `Microsoft Teams meeting deletion failed (${response.status})`,
179
+ );
180
+ }
181
+ },
182
+ };
183
+ }