The @microsoft/agents-hosting SDK's MsalTokenProvider automatically appends `/.default` to all scope strings in its token acquisition methods (acquireAccessTokenViaSecret, acquireAccessTokenViaFIC, acquireAccessTokenViaWID, acquireTokenWithCertificate in msalTokenProvider.ts). This is consistent SDK behavior, not a recent change. Our code was including `.default` in scope URLs, resulting in invalid double suffixes like `https://graph.microsoft.com/.default/.default`. This was confirmed to cause Graph API authentication errors. Removing the `.default` suffix from our scope strings allows the SDK to append it correctly, resolving the issue. Before: we pass `.default` -> SDK appends -> double `.default` (broken) After: we pass base URL -> SDK appends -> single `.default` (works) Co-authored-by: Christof Salis <c.salis@vertifymed.com>
446 lines
13 KiB
TypeScript
446 lines
13 KiB
TypeScript
/**
|
|
* OneDrive/SharePoint upload utilities for MS Teams file sending.
|
|
*
|
|
* For group chats and channels, files are uploaded to SharePoint and shared via a link.
|
|
* This module provides utilities for:
|
|
* - Uploading files to OneDrive (personal scope - now deprecated for bot use)
|
|
* - Uploading files to SharePoint (group/channel scope)
|
|
* - Creating sharing links (organization-wide or per-user)
|
|
* - Getting chat members for per-user sharing
|
|
*/
|
|
|
|
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
|
|
|
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
|
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
|
const GRAPH_SCOPE = "https://graph.microsoft.com";
|
|
|
|
export interface OneDriveUploadResult {
|
|
id: string;
|
|
webUrl: string;
|
|
name: string;
|
|
}
|
|
|
|
/**
|
|
* Upload a file to the user's OneDrive root folder.
|
|
* For larger files, this uses the simple upload endpoint (up to 4MB).
|
|
* TODO: For files >4MB, implement resumable upload session.
|
|
*/
|
|
export async function uploadToOneDrive(params: {
|
|
buffer: Buffer;
|
|
filename: string;
|
|
contentType?: string;
|
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
fetchFn?: typeof fetch;
|
|
}): Promise<OneDriveUploadResult> {
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
|
|
// Use "ClawdbotShared" folder to organize bot-uploaded files
|
|
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
|
|
|
|
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": params.contentType ?? "application/octet-stream",
|
|
},
|
|
body: new Uint8Array(params.buffer),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "");
|
|
throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
id?: string;
|
|
webUrl?: string;
|
|
name?: string;
|
|
};
|
|
|
|
if (!data.id || !data.webUrl || !data.name) {
|
|
throw new Error("OneDrive upload response missing required fields");
|
|
}
|
|
|
|
return {
|
|
id: data.id,
|
|
webUrl: data.webUrl,
|
|
name: data.name,
|
|
};
|
|
}
|
|
|
|
export interface OneDriveSharingLink {
|
|
webUrl: string;
|
|
}
|
|
|
|
/**
|
|
* Create a sharing link for a OneDrive file.
|
|
* The link allows organization members to view the file.
|
|
*/
|
|
export async function createSharingLink(params: {
|
|
itemId: string;
|
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
/** Sharing scope: "organization" (default) or "anonymous" */
|
|
scope?: "organization" | "anonymous";
|
|
fetchFn?: typeof fetch;
|
|
}): Promise<OneDriveSharingLink> {
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
|
|
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
type: "view",
|
|
scope: params.scope ?? "organization",
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "");
|
|
throw new Error(`Create sharing link failed: ${res.status} ${res.statusText} - ${body}`);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
link?: { webUrl?: string };
|
|
};
|
|
|
|
if (!data.link?.webUrl) {
|
|
throw new Error("Create sharing link response missing webUrl");
|
|
}
|
|
|
|
return {
|
|
webUrl: data.link.webUrl,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Upload a file to OneDrive and create a sharing link.
|
|
* Convenience function for the common case.
|
|
*/
|
|
export async function uploadAndShareOneDrive(params: {
|
|
buffer: Buffer;
|
|
filename: string;
|
|
contentType?: string;
|
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
scope?: "organization" | "anonymous";
|
|
fetchFn?: typeof fetch;
|
|
}): Promise<{
|
|
itemId: string;
|
|
webUrl: string;
|
|
shareUrl: string;
|
|
name: string;
|
|
}> {
|
|
const uploaded = await uploadToOneDrive({
|
|
buffer: params.buffer,
|
|
filename: params.filename,
|
|
contentType: params.contentType,
|
|
tokenProvider: params.tokenProvider,
|
|
fetchFn: params.fetchFn,
|
|
});
|
|
|
|
const shareLink = await createSharingLink({
|
|
itemId: uploaded.id,
|
|
tokenProvider: params.tokenProvider,
|
|
scope: params.scope,
|
|
fetchFn: params.fetchFn,
|
|
});
|
|
|
|
return {
|
|
itemId: uploaded.id,
|
|
webUrl: uploaded.webUrl,
|
|
shareUrl: shareLink.webUrl,
|
|
name: uploaded.name,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// SharePoint upload functions for group chats and channels
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Upload a file to a SharePoint site.
|
|
* This is used for group chats and channels where /me/drive doesn't work for bots.
|
|
*
|
|
* @param params.siteId - SharePoint site ID (e.g., "contoso.sharepoint.com,guid1,guid2")
|
|
*/
|
|
export async function uploadToSharePoint(params: {
|
|
buffer: Buffer;
|
|
filename: string;
|
|
contentType?: string;
|
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
siteId: string;
|
|
fetchFn?: typeof fetch;
|
|
}): Promise<OneDriveUploadResult> {
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
|
|
// Use "ClawdbotShared" folder to organize bot-uploaded files
|
|
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
|
|
|
|
const res = await fetchFn(`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": params.contentType ?? "application/octet-stream",
|
|
},
|
|
body: new Uint8Array(params.buffer),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "");
|
|
throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
id?: string;
|
|
webUrl?: string;
|
|
name?: string;
|
|
};
|
|
|
|
if (!data.id || !data.webUrl || !data.name) {
|
|
throw new Error("SharePoint upload response missing required fields");
|
|
}
|
|
|
|
return {
|
|
id: data.id,
|
|
webUrl: data.webUrl,
|
|
name: data.name,
|
|
};
|
|
}
|
|
|
|
export interface ChatMember {
|
|
aadObjectId: string;
|
|
displayName?: string;
|
|
}
|
|
|
|
/**
|
|
* Properties needed for native Teams file card attachments.
|
|
* The eTag is used as the attachment ID and webDavUrl as the contentUrl.
|
|
*/
|
|
export interface DriveItemProperties {
|
|
/** The eTag of the driveItem (used as attachment ID) */
|
|
eTag: string;
|
|
/** The WebDAV URL of the driveItem (used as contentUrl for reference attachment) */
|
|
webDavUrl: string;
|
|
/** The filename */
|
|
name: string;
|
|
}
|
|
|
|
/**
|
|
* Get driveItem properties needed for native Teams file card attachments.
|
|
* This fetches the eTag and webDavUrl which are required for "reference" type attachments.
|
|
*
|
|
* @param params.siteId - SharePoint site ID
|
|
* @param params.itemId - The driveItem ID (returned from upload)
|
|
*/
|
|
export async function getDriveItemProperties(params: {
|
|
siteId: string;
|
|
itemId: string;
|
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
fetchFn?: typeof fetch;
|
|
}): Promise<DriveItemProperties> {
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
|
|
const res = await fetchFn(
|
|
`${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
);
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "");
|
|
throw new Error(`Get driveItem properties failed: ${res.status} ${res.statusText} - ${body}`);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
eTag?: string;
|
|
webDavUrl?: string;
|
|
name?: string;
|
|
};
|
|
|
|
if (!data.eTag || !data.webDavUrl || !data.name) {
|
|
throw new Error("DriveItem response missing required properties (eTag, webDavUrl, or name)");
|
|
}
|
|
|
|
return {
|
|
eTag: data.eTag,
|
|
webDavUrl: data.webDavUrl,
|
|
name: data.name,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get members of a Teams chat for per-user sharing.
|
|
* Used to create sharing links scoped to only the chat participants.
|
|
*/
|
|
export async function getChatMembers(params: {
|
|
chatId: string;
|
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
fetchFn?: typeof fetch;
|
|
}): Promise<ChatMember[]> {
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
|
|
const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => "");
|
|
throw new Error(`Get chat members failed: ${res.status} ${res.statusText} - ${body}`);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
value?: Array<{
|
|
userId?: string;
|
|
displayName?: string;
|
|
}>;
|
|
};
|
|
|
|
return (data.value ?? [])
|
|
.map((m) => ({
|
|
aadObjectId: m.userId ?? "",
|
|
displayName: m.displayName,
|
|
}))
|
|
.filter((m) => m.aadObjectId);
|
|
}
|
|
|
|
/**
|
|
* Create a sharing link for a SharePoint drive item.
|
|
* For organization scope (default), uses v1.0 API.
|
|
* For per-user scope, uses beta API with recipients.
|
|
*/
|
|
export async function createSharePointSharingLink(params: {
|
|
siteId: string;
|
|
itemId: string;
|
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
/** Sharing scope: "organization" (default) or "users" (per-user with recipients) */
|
|
scope?: "organization" | "users";
|
|
/** Required when scope is "users": AAD object IDs of recipients */
|
|
recipientObjectIds?: string[];
|
|
fetchFn?: typeof fetch;
|
|
}): Promise<OneDriveSharingLink> {
|
|
const fetchFn = params.fetchFn ?? fetch;
|
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
|
const scope = params.scope ?? "organization";
|
|
|
|
// Per-user sharing requires beta API
|
|
const apiRoot = scope === "users" ? GRAPH_BETA : GRAPH_ROOT;
|
|
|
|
const body: Record<string, unknown> = {
|
|
type: "view",
|
|
scope: scope === "users" ? "users" : "organization",
|
|
};
|
|
|
|
// Add recipients for per-user sharing
|
|
if (scope === "users" && params.recipientObjectIds?.length) {
|
|
body.recipients = params.recipientObjectIds.map((id) => ({ objectId: id }));
|
|
}
|
|
|
|
const res = await fetchFn(`${apiRoot}/sites/${params.siteId}/drive/items/${params.itemId}/createLink`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const respBody = await res.text().catch(() => "");
|
|
throw new Error(`Create SharePoint sharing link failed: ${res.status} ${res.statusText} - ${respBody}`);
|
|
}
|
|
|
|
const data = (await res.json()) as {
|
|
link?: { webUrl?: string };
|
|
};
|
|
|
|
if (!data.link?.webUrl) {
|
|
throw new Error("Create SharePoint sharing link response missing webUrl");
|
|
}
|
|
|
|
return {
|
|
webUrl: data.link.webUrl,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Upload a file to SharePoint and create a sharing link.
|
|
*
|
|
* For group chats, this creates a per-user sharing link scoped to chat members.
|
|
* For channels, this creates an organization-wide sharing link.
|
|
*
|
|
* @param params.siteId - SharePoint site ID
|
|
* @param params.chatId - Optional chat ID for per-user sharing (group chats)
|
|
* @param params.usePerUserSharing - Whether to use per-user sharing (requires beta API + Chat.Read.All)
|
|
*/
|
|
export async function uploadAndShareSharePoint(params: {
|
|
buffer: Buffer;
|
|
filename: string;
|
|
contentType?: string;
|
|
tokenProvider: MSTeamsAccessTokenProvider;
|
|
siteId: string;
|
|
chatId?: string;
|
|
usePerUserSharing?: boolean;
|
|
fetchFn?: typeof fetch;
|
|
}): Promise<{
|
|
itemId: string;
|
|
webUrl: string;
|
|
shareUrl: string;
|
|
name: string;
|
|
}> {
|
|
// 1. Upload file to SharePoint
|
|
const uploaded = await uploadToSharePoint({
|
|
buffer: params.buffer,
|
|
filename: params.filename,
|
|
contentType: params.contentType,
|
|
tokenProvider: params.tokenProvider,
|
|
siteId: params.siteId,
|
|
fetchFn: params.fetchFn,
|
|
});
|
|
|
|
// 2. Determine sharing scope
|
|
let scope: "organization" | "users" = "organization";
|
|
let recipientObjectIds: string[] | undefined;
|
|
|
|
if (params.usePerUserSharing && params.chatId) {
|
|
try {
|
|
const members = await getChatMembers({
|
|
chatId: params.chatId,
|
|
tokenProvider: params.tokenProvider,
|
|
fetchFn: params.fetchFn,
|
|
});
|
|
|
|
if (members.length > 0) {
|
|
scope = "users";
|
|
recipientObjectIds = members.map((m) => m.aadObjectId);
|
|
}
|
|
} catch {
|
|
// Fall back to organization scope if we can't get chat members
|
|
// (e.g., missing Chat.Read.All permission)
|
|
}
|
|
}
|
|
|
|
// 3. Create sharing link
|
|
const shareLink = await createSharePointSharingLink({
|
|
siteId: params.siteId,
|
|
itemId: uploaded.id,
|
|
tokenProvider: params.tokenProvider,
|
|
scope,
|
|
recipientObjectIds,
|
|
fetchFn: params.fetchFn,
|
|
});
|
|
|
|
return {
|
|
itemId: uploaded.id,
|
|
webUrl: uploaded.webUrl,
|
|
shareUrl: shareLink.webUrl,
|
|
name: uploaded.name,
|
|
};
|
|
}
|