@agent-native/scheduling 0.1.23 → 0.1.25
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.
- package/README.md +16 -0
- package/agent-native.package.json +154 -0
- package/dist/actions/_helpers.d.ts +10 -0
- package/dist/actions/_helpers.d.ts.map +1 -1
- package/dist/actions/_helpers.js +14 -0
- package/dist/actions/_helpers.js.map +1 -1
- package/dist/actions/add-booking-attendee.js +2 -0
- package/dist/actions/add-booking-attendee.js.map +1 -1
- package/dist/actions/add-booking-note.js +2 -1
- package/dist/actions/add-booking-note.js.map +1 -1
- package/dist/actions/confirm-booking.js +5 -0
- package/dist/actions/confirm-booking.js.map +1 -1
- package/dist/actions/duplicate-event-type.js +2 -0
- package/dist/actions/duplicate-event-type.js.map +1 -1
- package/dist/actions/get-booking.d.ts +3 -1
- package/dist/actions/get-booking.js +8 -1
- package/dist/actions/get-booking.js.map +1 -1
- package/dist/actions/mark-no-show.js +6 -0
- package/dist/actions/mark-no-show.js.map +1 -1
- package/dist/actions/remove-booking-attendee.js +2 -0
- package/dist/actions/remove-booking-attendee.js.map +1 -1
- package/dist/actions/revoke-private-link.js +18 -1
- package/dist/actions/revoke-private-link.js.map +1 -1
- package/dist/actions/send-reschedule-link.js +2 -0
- package/dist/actions/send-reschedule-link.js.map +1 -1
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +6 -1
- package/dist/manifest.js.map +1 -1
- package/dist/server/availability-engine.d.ts +20 -0
- package/dist/server/availability-engine.d.ts.map +1 -1
- package/dist/server/availability-engine.js +73 -23
- package/dist/server/availability-engine.js.map +1 -1
- package/dist/server/booking-service.d.ts.map +1 -1
- package/dist/server/booking-service.js +16 -5
- package/dist/server/booking-service.js.map +1 -1
- package/dist/server/bookings-repo.d.ts.map +1 -1
- package/dist/server/bookings-repo.js +50 -48
- package/dist/server/bookings-repo.js.map +1 -1
- package/dist/server/providers/index.d.ts +2 -0
- package/dist/server/providers/index.d.ts.map +1 -1
- package/dist/server/providers/index.js +1 -0
- package/dist/server/providers/index.js.map +1 -1
- package/dist/server/providers/teams.d.ts +22 -0
- package/dist/server/providers/teams.d.ts.map +1 -0
- package/dist/server/providers/teams.js +112 -0
- package/dist/server/providers/teams.js.map +1 -0
- package/docs/eject.md +11 -10
- package/docs/llms-full.txt +75 -14
- package/docs/llms.txt +15 -1
- package/docs/providers.md +30 -2
- package/docs/skills/integrations/SKILL.md +19 -1
- package/package.json +7 -6
- package/src/actions/_helpers.ts +18 -0
- package/src/actions/add-booking-attendee.ts +2 -0
- package/src/actions/add-booking-note.ts +2 -1
- package/src/actions/booking-authz.spec.ts +288 -0
- package/src/actions/confirm-booking.ts +4 -0
- package/src/actions/duplicate-event-type.ts +2 -0
- package/src/actions/event-type-authz.spec.ts +209 -0
- package/src/actions/get-booking.ts +7 -1
- package/src/actions/mark-no-show.ts +5 -0
- package/src/actions/remove-booking-attendee.ts +2 -0
- package/src/actions/revoke-private-link.ts +16 -1
- package/src/actions/send-reschedule-link.ts +2 -0
- package/src/manifest.test.ts +19 -0
- package/src/manifest.ts +9 -7
- package/src/server/availability-engine.test.ts +344 -0
- package/src/server/availability-engine.ts +100 -22
- package/src/server/booking-service.spec.ts +557 -0
- package/src/server/booking-service.ts +17 -6
- package/src/server/bookings-repo.ts +54 -48
- package/src/server/providers/index.ts +2 -0
- package/src/server/providers/teams.test.ts +246 -0
- 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.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
+
}
|