import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from
"openclaw/plugin-sdk/text-runtime";
import { getMSTeamsRuntime } from
"../runtime.js";
import { downloadAndStoreMSTeamsRemoteMedia } from
"./remote-media.js";
import {
extractInlineImageCandidates,
inferPlaceholder,
isDownloadableAttachment,
isRecord,
isUrlAllowed,
type MSTeamsAttachmentDownloadLogger,
type MSTeamsAttachmentFetchPolicy,
type MSTeamsAttachmentResolveFn,
normalizeContentType,
resolveMediaSsrfPolicy,
resolveAttachmentFetchPolicy,
resolveRequestUrl,
safeFetchWithPolicy,
tryBuildGraphSharesUrlForSharedLink,
} from
"./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike,
MSTeamsInboundMedia,
} from
"./types.js";
type DownloadCandidate = {
url: string;
fileHint?: string;
contentTypeHint?: string;
placeholder: string;
};
function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate |
null {
const contentType = normalizeContentType(att.contentType);
const name = normalizeOptionalString(att.name) ??
"";
if (contentType ===
"application/vnd.microsoft.teams.file.download.info") {
if (!isRecord(att.content)) {
return null;
}
const downloadUrl = normalizeOptionalString(att.content.downloadUrl) ??
"";
if (!downloadUrl) {
return null;
}
const fileType = normalizeOptionalString(att.content.fileType) ??
"";
const uniqueId = normalizeOptionalString(att.content.uniqueId) ??
"";
const fileName = normalizeOptionalString(att.content.fileName) ??
"";
const fileHint = name || fileName || (uniqueId && fileType ? `${uniqueId}.${fileType}` :
"");
return {
url: downloadUrl,
fileHint: fileHint || undefined,
contentTypeHint: undefined,
placeholder: inferPlaceholder({
contentType,
fileName: fileHint,
fileType,
}),
};
}
const contentUrl = normalizeOptionalString(att.contentUrl) ??
"";
if (!contentUrl) {
return null;
}
// OneDrive/SharePoint shared links (delivered in 1:1 DMs when the user
// picks "Attach > OneDrive") cannot be fetched directly — the URL returns
// an HTML landing page rather than the file bytes. Rewrite them to the
// Graph shares endpoint so the auth fallback attaches a Graph-scoped token
// and the response is the real file content.
const sharesUrl = tryBuildGraphSharesUrlForSharedLink(contentUrl);
const resolvedUrl = sharesUrl ?? contentUrl;
// Graph shares returns raw bytes without a declared content type we can
// trust for routing — let the downloader infer MIME from the buffer.
const resolvedContentTypeHint = sharesUrl ? undefined : contentType;
return {
url: resolvedUrl,
fileHint: name || undefined,
contentTypeHint: resolvedContentTypeHint,
placeholder: inferPlaceholder({ contentType, fileName: name }),
};
}
function scopeCandidatesForUrl(url: string): string[] {
try {
const host = normalizeLowercaseStringOrEmpty(
new URL(url).hostname);
const looksLikeGraph =
host.endsWith(
"graph.microsoft.com") ||
host.endsWith(
"sharepoint.com") ||
host.endsWith(
"1drv.ms") ||
host.includes(
"sharepoint");
return looksLikeGraph
? [
"https://graph.microsoft.com", "https://api.botframework.com"]
: [
"https://api.botframework.com", "https://graph.microsoft.com"];
}
catch {
return [
"https://api.botframework.com", "https://graph.microsoft.com"];
}
}
function isRedirectStatus(status: number):
boolean {
return status ===
301 || status ===
302 || status ===
303 || status ===
307 || status ===
308;
}
async
function fetchWithAuthFallback(params: {
url: string;
tokenProvider?: MSTeamsAccessTokenProvider;
fetchFn?:
typeof fetch;
requestInit?: RequestInit;
resolveFn?: MSTeamsAttachmentResolveFn;
policy: MSTeamsAttachmentFetchPolicy;
}): Promise<Response> {
const firstAttempt = await safeFetchWithPolicy({
url: params.url,
policy: params.policy,
fetchFn: params.fetchFn,
requestInit: params.requestInit,
resolveFn: params.resolveFn,
});
if (firstAttempt.ok) {
return firstAttempt;
}
if (!params.tokenProvider) {
return firstAttempt;
}
if (firstAttempt.status !==
401 && firstAttempt.status !==
403) {
return firstAttempt;
}
if (!isUrlAllowed(params.url, params.policy.authAllowHosts)) {
return firstAttempt;
}
const scopes = scopeCandidatesForUrl(params.url);
const fetchFn = params.fetchFn ?? fetch;
for (
const scope of scopes) {
try {
const token = await params.tokenProvider.getAccessToken(scope);
const authHeaders =
new Headers(params.requestInit?.headers);
authHeaders.set(
"Authorization", `Bearer ${token}`);
const authAttempt = await safeFetchWithPolicy({
url: params.url,
policy: params.policy,
fetchFn,
requestInit: {
...params.requestInit,
headers: authHeaders,
},
resolveFn: params.resolveFn,
});
if (authAttempt.ok) {
return authAttempt;
}
if (isRedirectStatus(authAttempt.status)) {
// Redirects in guarded fetch mode must propagate to the outer guard.
return authAttempt;
}
if (authAttempt.status !==
401 && authAttempt.status !==
403) {
// Preserve scope fallback semantics for non-auth failures.
continue;
}
}
catch {
// Try the next scope.
}
}
return firstAttempt;
}
/**
* Download all file attachments from a Teams message (images, documents, etc.).
* Renamed from downloadMSTeamsImageAttachments to support all file types.
*/
export async
function downloadMSTeamsAttachments(params: {
attachments: MSTeamsAttachmentLike[] | undefined;
maxBytes: number;
tokenProvider?: MSTeamsAccessTokenProvider;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?:
typeof fetch;
resolveFn?: MSTeamsAttachmentResolveFn;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?:
boolean;
/**
* Optional logger used to surface inline data decode failures and remote
* media download errors. Errors that are not logged here are invisible at
* INFO level and block diagnosis of issues like #63396.
*/
logger?: MSTeamsAttachmentDownloadLogger;
}): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length ===
0) {
return [];
}
const policy = resolveAttachmentFetchPolicy({
allowHosts: params.allowHosts,
authAllowHosts: params.authAllowHosts,
});
const allowHosts = policy.allowHosts;
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
// Download ANY downloadable attachment (not just images)
const downloadable = list.filter(isDownloadableAttachment);
const candidates: DownloadCandidate[] = downloadable
.map(resolveDownloadCandidate)
.filter(
Boolean) as DownloadCandidate[];
const inlineCandidates = extractInlineImageCandidates(list, {
maxInlineBytes: params.maxBytes,
maxInlineTotalBytes: params.maxBytes,
});
const seenUrls =
new Set<string>();
for (
const inline of inlineCandidates) {
if (inline.kind ===
"url") {
if (!isUrlAllowed(inline.url, allowHosts)) {
continue;
}
if (seenUrls.has(inline.url)) {
continue;
}
seenUrls.add(inline.url);
candidates.push({
url: inline.url,
fileHint: inline.fileHint,
contentTypeHint: inline.contentType,
placeholder: inline.placeholder,
});
}
}
if (candidates.length ===
0 && inlineCandidates.length ===
0) {
return [];
}
const out: MSTeamsInboundMedia[] = [];
for (
const inline of inlineCandidates) {
if (inline.kind !==
"data") {
continue;
}
if (inline.data.byteLength > params.maxBytes) {
continue;
}
try {
// Data inline candidates (base64 data URLs) don't have original filenames
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
inline.data,
inline.contentType,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inline.placeholder,
});
}
catch (err) {
params.logger?.warn?.(
"msteams inline attachment decode failed", {
error: err
instanceof Error ? err.message : String(err),
});
}
}
for (
const candidate of candidates) {
if (!isUrlAllowed(candidate.url, allowHosts)) {
continue;
}
try {
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: candidate.url,
filePathHint: candidate.fileHint ?? candidate.url,
maxBytes: params.maxBytes,
contentTypeHint: candidate.contentTypeHint,
placeholder: candidate.placeholder,
preserveFilenames: params.preserveFilenames,
ssrfPolicy,
// `fetchImpl` below already validates each hop against the hostname
// allowlist via `safeFetchWithPolicy`, so skip `fetchRemoteMedia`'s
// strict SSRF dispatcher (incompatible with Node 24+ / undici v7;
// see issue #63396).
useDirectFetch:
true,
fetchImpl: (input, init) =>
fetchWithAuthFallback({
url: resolveRequestUrl(input),
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
requestInit: init,
resolveFn: params.resolveFn,
policy,
}),
});
out.push(media);
}
catch (err) {
params.logger?.warn?.(
"msteams attachment download failed", {
error: err
instanceof Error ? err.message : String(err),
host: safeHostForLog(candidate.url),
});
}
}
return out;
}
function safeHostForLog(url: string): string {
try {
return new URL(url).host;
}
catch {
return "invalid-url";
}
}
/**
* @deprecated Use `downloadMSTeamsAttachments` instead (supports all file types).
*/
export
const downloadMSTeamsImageAttachments = downloadMSTeamsAttachments;