Files
clawdbot/extensions/msteams/src/graph-upload.ts
Christof ef777d6bb6 fix(msteams): remove .default suffix from graph scopes (#1507)
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>
2026-01-24 00:07:22 +00:00

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,
};
}