@openclaw/msteams 2026.1.29

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 (61) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/index.ts +18 -0
  3. package/openclaw.plugin.json +11 -0
  4. package/package.json +36 -0
  5. package/src/attachments/download.ts +206 -0
  6. package/src/attachments/graph.ts +319 -0
  7. package/src/attachments/html.ts +76 -0
  8. package/src/attachments/payload.ts +22 -0
  9. package/src/attachments/shared.ts +235 -0
  10. package/src/attachments/types.ts +37 -0
  11. package/src/attachments.test.ts +424 -0
  12. package/src/attachments.ts +18 -0
  13. package/src/channel.directory.test.ts +46 -0
  14. package/src/channel.ts +436 -0
  15. package/src/conversation-store-fs.test.ts +89 -0
  16. package/src/conversation-store-fs.ts +155 -0
  17. package/src/conversation-store-memory.ts +45 -0
  18. package/src/conversation-store.ts +41 -0
  19. package/src/directory-live.ts +179 -0
  20. package/src/errors.test.ts +46 -0
  21. package/src/errors.ts +158 -0
  22. package/src/file-consent-helpers.test.ts +234 -0
  23. package/src/file-consent-helpers.ts +73 -0
  24. package/src/file-consent.ts +122 -0
  25. package/src/graph-chat.ts +52 -0
  26. package/src/graph-upload.ts +445 -0
  27. package/src/inbound.test.ts +67 -0
  28. package/src/inbound.ts +38 -0
  29. package/src/index.ts +4 -0
  30. package/src/media-helpers.test.ts +186 -0
  31. package/src/media-helpers.ts +77 -0
  32. package/src/messenger.test.ts +245 -0
  33. package/src/messenger.ts +460 -0
  34. package/src/monitor-handler/inbound-media.ts +123 -0
  35. package/src/monitor-handler/message-handler.ts +629 -0
  36. package/src/monitor-handler.ts +166 -0
  37. package/src/monitor-types.ts +5 -0
  38. package/src/monitor.ts +290 -0
  39. package/src/onboarding.ts +432 -0
  40. package/src/outbound.ts +47 -0
  41. package/src/pending-uploads.ts +87 -0
  42. package/src/policy.test.ts +210 -0
  43. package/src/policy.ts +247 -0
  44. package/src/polls-store-memory.ts +30 -0
  45. package/src/polls-store.test.ts +40 -0
  46. package/src/polls.test.ts +73 -0
  47. package/src/polls.ts +300 -0
  48. package/src/probe.test.ts +57 -0
  49. package/src/probe.ts +99 -0
  50. package/src/reply-dispatcher.ts +128 -0
  51. package/src/resolve-allowlist.ts +277 -0
  52. package/src/runtime.ts +14 -0
  53. package/src/sdk-types.ts +19 -0
  54. package/src/sdk.ts +33 -0
  55. package/src/send-context.ts +156 -0
  56. package/src/send.ts +489 -0
  57. package/src/sent-message-cache.test.ts +16 -0
  58. package/src/sent-message-cache.ts +41 -0
  59. package/src/storage.ts +22 -0
  60. package/src/store-fs.ts +80 -0
  61. package/src/token.ts +19 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared helpers for FileConsentCard flow in MSTeams.
3
+ *
4
+ * FileConsentCard is required for:
5
+ * - Personal (1:1) chats with large files (>=4MB)
6
+ * - Personal chats with non-image files (PDFs, documents, etc.)
7
+ *
8
+ * This module consolidates the logic used by both send.ts (proactive sends)
9
+ * and messenger.ts (reply path) to avoid duplication.
10
+ */
11
+
12
+ import { buildFileConsentCard } from "./file-consent.js";
13
+ import { storePendingUpload } from "./pending-uploads.js";
14
+
15
+ export type FileConsentMedia = {
16
+ buffer: Buffer;
17
+ filename: string;
18
+ contentType?: string;
19
+ };
20
+
21
+ export type FileConsentActivityResult = {
22
+ activity: Record<string, unknown>;
23
+ uploadId: string;
24
+ };
25
+
26
+ /**
27
+ * Prepare a FileConsentCard activity for large files or non-images in personal chats.
28
+ * Returns the activity object and uploadId - caller is responsible for sending.
29
+ */
30
+ export function prepareFileConsentActivity(params: {
31
+ media: FileConsentMedia;
32
+ conversationId: string;
33
+ description?: string;
34
+ }): FileConsentActivityResult {
35
+ const { media, conversationId, description } = params;
36
+
37
+ const uploadId = storePendingUpload({
38
+ buffer: media.buffer,
39
+ filename: media.filename,
40
+ contentType: media.contentType,
41
+ conversationId,
42
+ });
43
+
44
+ const consentCard = buildFileConsentCard({
45
+ filename: media.filename,
46
+ description: description || `File: ${media.filename}`,
47
+ sizeInBytes: media.buffer.length,
48
+ context: { uploadId },
49
+ });
50
+
51
+ const activity: Record<string, unknown> = {
52
+ type: "message",
53
+ attachments: [consentCard],
54
+ };
55
+
56
+ return { activity, uploadId };
57
+ }
58
+
59
+ /**
60
+ * Check if a file requires FileConsentCard flow.
61
+ * True for: personal chat AND (large file OR non-image)
62
+ */
63
+ export function requiresFileConsent(params: {
64
+ conversationType: string | undefined;
65
+ contentType: string | undefined;
66
+ bufferSize: number;
67
+ thresholdBytes: number;
68
+ }): boolean {
69
+ const isPersonal = params.conversationType?.toLowerCase() === "personal";
70
+ const isImage = params.contentType?.startsWith("image/") ?? false;
71
+ const isLargeFile = params.bufferSize >= params.thresholdBytes;
72
+ return isPersonal && (isLargeFile || !isImage);
73
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * FileConsentCard utilities for MS Teams large file uploads (>4MB) in personal chats.
3
+ *
4
+ * Teams requires user consent before the bot can upload large files. This module provides
5
+ * utilities for:
6
+ * - Building FileConsentCard attachments (to request upload permission)
7
+ * - Building FileInfoCard attachments (to confirm upload completion)
8
+ * - Parsing fileConsent/invoke activities
9
+ */
10
+
11
+ export interface FileConsentCardParams {
12
+ filename: string;
13
+ description?: string;
14
+ sizeInBytes: number;
15
+ /** Custom context data to include in the card (passed back in the invoke) */
16
+ context?: Record<string, unknown>;
17
+ }
18
+
19
+ export interface FileInfoCardParams {
20
+ filename: string;
21
+ contentUrl: string;
22
+ uniqueId: string;
23
+ fileType: string;
24
+ }
25
+
26
+ /**
27
+ * Build a FileConsentCard attachment for requesting upload permission.
28
+ * Use this for files >= 4MB in personal (1:1) chats.
29
+ */
30
+ export function buildFileConsentCard(params: FileConsentCardParams) {
31
+ return {
32
+ contentType: "application/vnd.microsoft.teams.card.file.consent",
33
+ name: params.filename,
34
+ content: {
35
+ description: params.description ?? `File: ${params.filename}`,
36
+ sizeInBytes: params.sizeInBytes,
37
+ acceptContext: { filename: params.filename, ...params.context },
38
+ declineContext: { filename: params.filename, ...params.context },
39
+ },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Build a FileInfoCard attachment for confirming upload completion.
45
+ * Send this after successfully uploading the file to the consent URL.
46
+ */
47
+ export function buildFileInfoCard(params: FileInfoCardParams) {
48
+ return {
49
+ contentType: "application/vnd.microsoft.teams.card.file.info",
50
+ contentUrl: params.contentUrl,
51
+ name: params.filename,
52
+ content: {
53
+ uniqueId: params.uniqueId,
54
+ fileType: params.fileType,
55
+ },
56
+ };
57
+ }
58
+
59
+ export interface FileConsentUploadInfo {
60
+ name: string;
61
+ uploadUrl: string;
62
+ contentUrl: string;
63
+ uniqueId: string;
64
+ fileType: string;
65
+ }
66
+
67
+ export interface FileConsentResponse {
68
+ action: "accept" | "decline";
69
+ uploadInfo?: FileConsentUploadInfo;
70
+ context?: Record<string, unknown>;
71
+ }
72
+
73
+ /**
74
+ * Parse a fileConsent/invoke activity.
75
+ * Returns null if the activity is not a file consent invoke.
76
+ */
77
+ export function parseFileConsentInvoke(activity: {
78
+ name?: string;
79
+ value?: unknown;
80
+ }): FileConsentResponse | null {
81
+ if (activity.name !== "fileConsent/invoke") return null;
82
+
83
+ const value = activity.value as {
84
+ type?: string;
85
+ action?: string;
86
+ uploadInfo?: FileConsentUploadInfo;
87
+ context?: Record<string, unknown>;
88
+ };
89
+
90
+ if (value?.type !== "fileUpload") return null;
91
+
92
+ return {
93
+ action: value.action === "accept" ? "accept" : "decline",
94
+ uploadInfo: value.uploadInfo,
95
+ context: value.context,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Upload a file to the consent URL provided by Teams.
101
+ * The URL is provided in the fileConsent/invoke response after user accepts.
102
+ */
103
+ export async function uploadToConsentUrl(params: {
104
+ url: string;
105
+ buffer: Buffer;
106
+ contentType?: string;
107
+ fetchFn?: typeof fetch;
108
+ }): Promise<void> {
109
+ const fetchFn = params.fetchFn ?? fetch;
110
+ const res = await fetchFn(params.url, {
111
+ method: "PUT",
112
+ headers: {
113
+ "Content-Type": params.contentType ?? "application/octet-stream",
114
+ "Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`,
115
+ },
116
+ body: new Uint8Array(params.buffer),
117
+ });
118
+
119
+ if (!res.ok) {
120
+ throw new Error(`File upload to consent URL failed: ${res.status} ${res.statusText}`);
121
+ }
122
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Native Teams file card attachments for Bot Framework.
3
+ *
4
+ * The Bot Framework SDK supports `application/vnd.microsoft.teams.card.file.info`
5
+ * content type which produces native Teams file cards.
6
+ *
7
+ * @see https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4
8
+ */
9
+
10
+ import type { DriveItemProperties } from "./graph-upload.js";
11
+
12
+ /**
13
+ * Build a native Teams file card attachment for Bot Framework.
14
+ *
15
+ * This uses the `application/vnd.microsoft.teams.card.file.info` content type
16
+ * which is supported by Bot Framework and produces native Teams file cards
17
+ * (the same display as when a user manually shares a file).
18
+ *
19
+ * @param file - DriveItem properties from getDriveItemProperties()
20
+ * @returns Attachment object for Bot Framework sendActivity()
21
+ */
22
+ export function buildTeamsFileInfoCard(file: DriveItemProperties): {
23
+ contentType: string;
24
+ contentUrl: string;
25
+ name: string;
26
+ content: {
27
+ uniqueId: string;
28
+ fileType: string;
29
+ };
30
+ } {
31
+ // Extract unique ID from eTag (remove quotes, braces, and version suffix)
32
+ // Example eTag formats: "{GUID},version" or "\"{GUID},version\""
33
+ const rawETag = file.eTag;
34
+ const uniqueId = rawETag
35
+ .replace(/^["']|["']$/g, "") // Remove outer quotes
36
+ .replace(/[{}]/g, "") // Remove curly braces
37
+ .split(",")[0] ?? rawETag; // Take the GUID part before comma
38
+
39
+ // Extract file extension from filename
40
+ const lastDot = file.name.lastIndexOf(".");
41
+ const fileType = lastDot >= 0 ? file.name.slice(lastDot + 1).toLowerCase() : "";
42
+
43
+ return {
44
+ contentType: "application/vnd.microsoft.teams.card.file.info",
45
+ contentUrl: file.webDavUrl,
46
+ name: file.name,
47
+ content: {
48
+ uniqueId,
49
+ fileType,
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,445 @@
1
+ /**
2
+ * OneDrive/SharePoint upload utilities for MS Teams file sending.
3
+ *
4
+ * For group chats and channels, files are uploaded to SharePoint and shared via a link.
5
+ * This module provides utilities for:
6
+ * - Uploading files to OneDrive (personal scope - now deprecated for bot use)
7
+ * - Uploading files to SharePoint (group/channel scope)
8
+ * - Creating sharing links (organization-wide or per-user)
9
+ * - Getting chat members for per-user sharing
10
+ */
11
+
12
+ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
13
+
14
+ const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
15
+ const GRAPH_BETA = "https://graph.microsoft.com/beta";
16
+ const GRAPH_SCOPE = "https://graph.microsoft.com";
17
+
18
+ export interface OneDriveUploadResult {
19
+ id: string;
20
+ webUrl: string;
21
+ name: string;
22
+ }
23
+
24
+ /**
25
+ * Upload a file to the user's OneDrive root folder.
26
+ * For larger files, this uses the simple upload endpoint (up to 4MB).
27
+ * TODO: For files >4MB, implement resumable upload session.
28
+ */
29
+ export async function uploadToOneDrive(params: {
30
+ buffer: Buffer;
31
+ filename: string;
32
+ contentType?: string;
33
+ tokenProvider: MSTeamsAccessTokenProvider;
34
+ fetchFn?: typeof fetch;
35
+ }): Promise<OneDriveUploadResult> {
36
+ const fetchFn = params.fetchFn ?? fetch;
37
+ const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
38
+
39
+ // Use "OpenClawShared" folder to organize bot-uploaded files
40
+ const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
41
+
42
+ const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
43
+ method: "PUT",
44
+ headers: {
45
+ Authorization: `Bearer ${token}`,
46
+ "Content-Type": params.contentType ?? "application/octet-stream",
47
+ },
48
+ body: new Uint8Array(params.buffer),
49
+ });
50
+
51
+ if (!res.ok) {
52
+ const body = await res.text().catch(() => "");
53
+ throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
54
+ }
55
+
56
+ const data = (await res.json()) as {
57
+ id?: string;
58
+ webUrl?: string;
59
+ name?: string;
60
+ };
61
+
62
+ if (!data.id || !data.webUrl || !data.name) {
63
+ throw new Error("OneDrive upload response missing required fields");
64
+ }
65
+
66
+ return {
67
+ id: data.id,
68
+ webUrl: data.webUrl,
69
+ name: data.name,
70
+ };
71
+ }
72
+
73
+ export interface OneDriveSharingLink {
74
+ webUrl: string;
75
+ }
76
+
77
+ /**
78
+ * Create a sharing link for a OneDrive file.
79
+ * The link allows organization members to view the file.
80
+ */
81
+ export async function createSharingLink(params: {
82
+ itemId: string;
83
+ tokenProvider: MSTeamsAccessTokenProvider;
84
+ /** Sharing scope: "organization" (default) or "anonymous" */
85
+ scope?: "organization" | "anonymous";
86
+ fetchFn?: typeof fetch;
87
+ }): Promise<OneDriveSharingLink> {
88
+ const fetchFn = params.fetchFn ?? fetch;
89
+ const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
90
+
91
+ const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
92
+ method: "POST",
93
+ headers: {
94
+ Authorization: `Bearer ${token}`,
95
+ "Content-Type": "application/json",
96
+ },
97
+ body: JSON.stringify({
98
+ type: "view",
99
+ scope: params.scope ?? "organization",
100
+ }),
101
+ });
102
+
103
+ if (!res.ok) {
104
+ const body = await res.text().catch(() => "");
105
+ throw new Error(`Create sharing link failed: ${res.status} ${res.statusText} - ${body}`);
106
+ }
107
+
108
+ const data = (await res.json()) as {
109
+ link?: { webUrl?: string };
110
+ };
111
+
112
+ if (!data.link?.webUrl) {
113
+ throw new Error("Create sharing link response missing webUrl");
114
+ }
115
+
116
+ return {
117
+ webUrl: data.link.webUrl,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Upload a file to OneDrive and create a sharing link.
123
+ * Convenience function for the common case.
124
+ */
125
+ export async function uploadAndShareOneDrive(params: {
126
+ buffer: Buffer;
127
+ filename: string;
128
+ contentType?: string;
129
+ tokenProvider: MSTeamsAccessTokenProvider;
130
+ scope?: "organization" | "anonymous";
131
+ fetchFn?: typeof fetch;
132
+ }): Promise<{
133
+ itemId: string;
134
+ webUrl: string;
135
+ shareUrl: string;
136
+ name: string;
137
+ }> {
138
+ const uploaded = await uploadToOneDrive({
139
+ buffer: params.buffer,
140
+ filename: params.filename,
141
+ contentType: params.contentType,
142
+ tokenProvider: params.tokenProvider,
143
+ fetchFn: params.fetchFn,
144
+ });
145
+
146
+ const shareLink = await createSharingLink({
147
+ itemId: uploaded.id,
148
+ tokenProvider: params.tokenProvider,
149
+ scope: params.scope,
150
+ fetchFn: params.fetchFn,
151
+ });
152
+
153
+ return {
154
+ itemId: uploaded.id,
155
+ webUrl: uploaded.webUrl,
156
+ shareUrl: shareLink.webUrl,
157
+ name: uploaded.name,
158
+ };
159
+ }
160
+
161
+ // ============================================================================
162
+ // SharePoint upload functions for group chats and channels
163
+ // ============================================================================
164
+
165
+ /**
166
+ * Upload a file to a SharePoint site.
167
+ * This is used for group chats and channels where /me/drive doesn't work for bots.
168
+ *
169
+ * @param params.siteId - SharePoint site ID (e.g., "contoso.sharepoint.com,guid1,guid2")
170
+ */
171
+ export async function uploadToSharePoint(params: {
172
+ buffer: Buffer;
173
+ filename: string;
174
+ contentType?: string;
175
+ tokenProvider: MSTeamsAccessTokenProvider;
176
+ siteId: string;
177
+ fetchFn?: typeof fetch;
178
+ }): Promise<OneDriveUploadResult> {
179
+ const fetchFn = params.fetchFn ?? fetch;
180
+ const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
181
+
182
+ // Use "OpenClawShared" folder to organize bot-uploaded files
183
+ const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
184
+
185
+ const res = await fetchFn(`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, {
186
+ method: "PUT",
187
+ headers: {
188
+ Authorization: `Bearer ${token}`,
189
+ "Content-Type": params.contentType ?? "application/octet-stream",
190
+ },
191
+ body: new Uint8Array(params.buffer),
192
+ });
193
+
194
+ if (!res.ok) {
195
+ const body = await res.text().catch(() => "");
196
+ throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
197
+ }
198
+
199
+ const data = (await res.json()) as {
200
+ id?: string;
201
+ webUrl?: string;
202
+ name?: string;
203
+ };
204
+
205
+ if (!data.id || !data.webUrl || !data.name) {
206
+ throw new Error("SharePoint upload response missing required fields");
207
+ }
208
+
209
+ return {
210
+ id: data.id,
211
+ webUrl: data.webUrl,
212
+ name: data.name,
213
+ };
214
+ }
215
+
216
+ export interface ChatMember {
217
+ aadObjectId: string;
218
+ displayName?: string;
219
+ }
220
+
221
+ /**
222
+ * Properties needed for native Teams file card attachments.
223
+ * The eTag is used as the attachment ID and webDavUrl as the contentUrl.
224
+ */
225
+ export interface DriveItemProperties {
226
+ /** The eTag of the driveItem (used as attachment ID) */
227
+ eTag: string;
228
+ /** The WebDAV URL of the driveItem (used as contentUrl for reference attachment) */
229
+ webDavUrl: string;
230
+ /** The filename */
231
+ name: string;
232
+ }
233
+
234
+ /**
235
+ * Get driveItem properties needed for native Teams file card attachments.
236
+ * This fetches the eTag and webDavUrl which are required for "reference" type attachments.
237
+ *
238
+ * @param params.siteId - SharePoint site ID
239
+ * @param params.itemId - The driveItem ID (returned from upload)
240
+ */
241
+ export async function getDriveItemProperties(params: {
242
+ siteId: string;
243
+ itemId: string;
244
+ tokenProvider: MSTeamsAccessTokenProvider;
245
+ fetchFn?: typeof fetch;
246
+ }): Promise<DriveItemProperties> {
247
+ const fetchFn = params.fetchFn ?? fetch;
248
+ const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
249
+
250
+ const res = await fetchFn(
251
+ `${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
252
+ { headers: { Authorization: `Bearer ${token}` } },
253
+ );
254
+
255
+ if (!res.ok) {
256
+ const body = await res.text().catch(() => "");
257
+ throw new Error(`Get driveItem properties failed: ${res.status} ${res.statusText} - ${body}`);
258
+ }
259
+
260
+ const data = (await res.json()) as {
261
+ eTag?: string;
262
+ webDavUrl?: string;
263
+ name?: string;
264
+ };
265
+
266
+ if (!data.eTag || !data.webDavUrl || !data.name) {
267
+ throw new Error("DriveItem response missing required properties (eTag, webDavUrl, or name)");
268
+ }
269
+
270
+ return {
271
+ eTag: data.eTag,
272
+ webDavUrl: data.webDavUrl,
273
+ name: data.name,
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Get members of a Teams chat for per-user sharing.
279
+ * Used to create sharing links scoped to only the chat participants.
280
+ */
281
+ export async function getChatMembers(params: {
282
+ chatId: string;
283
+ tokenProvider: MSTeamsAccessTokenProvider;
284
+ fetchFn?: typeof fetch;
285
+ }): Promise<ChatMember[]> {
286
+ const fetchFn = params.fetchFn ?? fetch;
287
+ const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
288
+
289
+ const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
290
+ headers: { Authorization: `Bearer ${token}` },
291
+ });
292
+
293
+ if (!res.ok) {
294
+ const body = await res.text().catch(() => "");
295
+ throw new Error(`Get chat members failed: ${res.status} ${res.statusText} - ${body}`);
296
+ }
297
+
298
+ const data = (await res.json()) as {
299
+ value?: Array<{
300
+ userId?: string;
301
+ displayName?: string;
302
+ }>;
303
+ };
304
+
305
+ return (data.value ?? [])
306
+ .map((m) => ({
307
+ aadObjectId: m.userId ?? "",
308
+ displayName: m.displayName,
309
+ }))
310
+ .filter((m) => m.aadObjectId);
311
+ }
312
+
313
+ /**
314
+ * Create a sharing link for a SharePoint drive item.
315
+ * For organization scope (default), uses v1.0 API.
316
+ * For per-user scope, uses beta API with recipients.
317
+ */
318
+ export async function createSharePointSharingLink(params: {
319
+ siteId: string;
320
+ itemId: string;
321
+ tokenProvider: MSTeamsAccessTokenProvider;
322
+ /** Sharing scope: "organization" (default) or "users" (per-user with recipients) */
323
+ scope?: "organization" | "users";
324
+ /** Required when scope is "users": AAD object IDs of recipients */
325
+ recipientObjectIds?: string[];
326
+ fetchFn?: typeof fetch;
327
+ }): Promise<OneDriveSharingLink> {
328
+ const fetchFn = params.fetchFn ?? fetch;
329
+ const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
330
+ const scope = params.scope ?? "organization";
331
+
332
+ // Per-user sharing requires beta API
333
+ const apiRoot = scope === "users" ? GRAPH_BETA : GRAPH_ROOT;
334
+
335
+ const body: Record<string, unknown> = {
336
+ type: "view",
337
+ scope: scope === "users" ? "users" : "organization",
338
+ };
339
+
340
+ // Add recipients for per-user sharing
341
+ if (scope === "users" && params.recipientObjectIds?.length) {
342
+ body.recipients = params.recipientObjectIds.map((id) => ({ objectId: id }));
343
+ }
344
+
345
+ const res = await fetchFn(`${apiRoot}/sites/${params.siteId}/drive/items/${params.itemId}/createLink`, {
346
+ method: "POST",
347
+ headers: {
348
+ Authorization: `Bearer ${token}`,
349
+ "Content-Type": "application/json",
350
+ },
351
+ body: JSON.stringify(body),
352
+ });
353
+
354
+ if (!res.ok) {
355
+ const respBody = await res.text().catch(() => "");
356
+ throw new Error(`Create SharePoint sharing link failed: ${res.status} ${res.statusText} - ${respBody}`);
357
+ }
358
+
359
+ const data = (await res.json()) as {
360
+ link?: { webUrl?: string };
361
+ };
362
+
363
+ if (!data.link?.webUrl) {
364
+ throw new Error("Create SharePoint sharing link response missing webUrl");
365
+ }
366
+
367
+ return {
368
+ webUrl: data.link.webUrl,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Upload a file to SharePoint and create a sharing link.
374
+ *
375
+ * For group chats, this creates a per-user sharing link scoped to chat members.
376
+ * For channels, this creates an organization-wide sharing link.
377
+ *
378
+ * @param params.siteId - SharePoint site ID
379
+ * @param params.chatId - Optional chat ID for per-user sharing (group chats)
380
+ * @param params.usePerUserSharing - Whether to use per-user sharing (requires beta API + Chat.Read.All)
381
+ */
382
+ export async function uploadAndShareSharePoint(params: {
383
+ buffer: Buffer;
384
+ filename: string;
385
+ contentType?: string;
386
+ tokenProvider: MSTeamsAccessTokenProvider;
387
+ siteId: string;
388
+ chatId?: string;
389
+ usePerUserSharing?: boolean;
390
+ fetchFn?: typeof fetch;
391
+ }): Promise<{
392
+ itemId: string;
393
+ webUrl: string;
394
+ shareUrl: string;
395
+ name: string;
396
+ }> {
397
+ // 1. Upload file to SharePoint
398
+ const uploaded = await uploadToSharePoint({
399
+ buffer: params.buffer,
400
+ filename: params.filename,
401
+ contentType: params.contentType,
402
+ tokenProvider: params.tokenProvider,
403
+ siteId: params.siteId,
404
+ fetchFn: params.fetchFn,
405
+ });
406
+
407
+ // 2. Determine sharing scope
408
+ let scope: "organization" | "users" = "organization";
409
+ let recipientObjectIds: string[] | undefined;
410
+
411
+ if (params.usePerUserSharing && params.chatId) {
412
+ try {
413
+ const members = await getChatMembers({
414
+ chatId: params.chatId,
415
+ tokenProvider: params.tokenProvider,
416
+ fetchFn: params.fetchFn,
417
+ });
418
+
419
+ if (members.length > 0) {
420
+ scope = "users";
421
+ recipientObjectIds = members.map((m) => m.aadObjectId);
422
+ }
423
+ } catch {
424
+ // Fall back to organization scope if we can't get chat members
425
+ // (e.g., missing Chat.Read.All permission)
426
+ }
427
+ }
428
+
429
+ // 3. Create sharing link
430
+ const shareLink = await createSharePointSharingLink({
431
+ siteId: params.siteId,
432
+ itemId: uploaded.id,
433
+ tokenProvider: params.tokenProvider,
434
+ scope,
435
+ recipientObjectIds,
436
+ fetchFn: params.fetchFn,
437
+ });
438
+
439
+ return {
440
+ itemId: uploaded.id,
441
+ webUrl: uploaded.webUrl,
442
+ shareUrl: shareLink.webUrl,
443
+ name: uploaded.name,
444
+ };
445
+ }