refactor(src): split oversized modules
This commit is contained in:
219
src/msteams/attachments/download.ts
Normal file
219
src/msteams/attachments/download.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { detectMime } from "../../media/mime.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import {
|
||||
extractInlineImageCandidates,
|
||||
inferPlaceholder,
|
||||
isLikelyImageAttachment,
|
||||
isRecord,
|
||||
isUrlAllowed,
|
||||
normalizeContentType,
|
||||
resolveAllowedHosts,
|
||||
} 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 = typeof att.name === "string" ? att.name.trim() : "";
|
||||
|
||||
if (contentType === "application/vnd.microsoft.teams.file.download.info") {
|
||||
if (!isRecord(att.content)) return null;
|
||||
const downloadUrl =
|
||||
typeof att.content.downloadUrl === "string"
|
||||
? att.content.downloadUrl.trim()
|
||||
: "";
|
||||
if (!downloadUrl) return null;
|
||||
|
||||
const fileType =
|
||||
typeof att.content.fileType === "string"
|
||||
? att.content.fileType.trim()
|
||||
: "";
|
||||
const uniqueId =
|
||||
typeof att.content.uniqueId === "string"
|
||||
? att.content.uniqueId.trim()
|
||||
: "";
|
||||
const fileName =
|
||||
typeof att.content.fileName === "string"
|
||||
? att.content.fileName.trim()
|
||||
: "";
|
||||
|
||||
const fileHint =
|
||||
name ||
|
||||
fileName ||
|
||||
(uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
|
||||
return {
|
||||
url: downloadUrl,
|
||||
fileHint: fileHint || undefined,
|
||||
contentTypeHint: undefined,
|
||||
placeholder: inferPlaceholder({
|
||||
contentType,
|
||||
fileName: fileHint,
|
||||
fileType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const contentUrl =
|
||||
typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
|
||||
if (!contentUrl) return null;
|
||||
|
||||
return {
|
||||
url: contentUrl,
|
||||
fileHint: name || undefined,
|
||||
contentTypeHint: contentType,
|
||||
placeholder: inferPlaceholder({ contentType, fileName: name }),
|
||||
};
|
||||
}
|
||||
|
||||
function scopeCandidatesForUrl(url: string): string[] {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
const looksLikeGraph =
|
||||
host.endsWith("graph.microsoft.com") ||
|
||||
host.endsWith("sharepoint.com") ||
|
||||
host.endsWith("1drv.ms") ||
|
||||
host.includes("sharepoint");
|
||||
return looksLikeGraph
|
||||
? [
|
||||
"https://graph.microsoft.com/.default",
|
||||
"https://api.botframework.com/.default",
|
||||
]
|
||||
: [
|
||||
"https://api.botframework.com/.default",
|
||||
"https://graph.microsoft.com/.default",
|
||||
];
|
||||
} catch {
|
||||
return [
|
||||
"https://api.botframework.com/.default",
|
||||
"https://graph.microsoft.com/.default",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithAuthFallback(params: {
|
||||
url: string;
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<Response> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const firstAttempt = await fetchFn(params.url);
|
||||
if (firstAttempt.ok) return firstAttempt;
|
||||
if (!params.tokenProvider) return firstAttempt;
|
||||
if (firstAttempt.status !== 401 && firstAttempt.status !== 403)
|
||||
return firstAttempt;
|
||||
|
||||
const scopes = scopeCandidatesForUrl(params.url);
|
||||
for (const scope of scopes) {
|
||||
try {
|
||||
const token = await params.tokenProvider.getAccessToken(scope);
|
||||
const res = await fetchFn(params.url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) return res;
|
||||
} catch {
|
||||
// Try the next scope.
|
||||
}
|
||||
}
|
||||
|
||||
return firstAttempt;
|
||||
}
|
||||
|
||||
export async function downloadMSTeamsImageAttachments(params: {
|
||||
attachments: MSTeamsAttachmentLike[] | undefined;
|
||||
maxBytes: number;
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
allowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<MSTeamsInboundMedia[]> {
|
||||
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
||||
if (list.length === 0) return [];
|
||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||
|
||||
const candidates: DownloadCandidate[] = list
|
||||
.filter(isLikelyImageAttachment)
|
||||
.map(resolveDownloadCandidate)
|
||||
.filter(Boolean) as DownloadCandidate[];
|
||||
|
||||
const inlineCandidates = extractInlineImageCandidates(list);
|
||||
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 {
|
||||
const saved = await saveMediaBuffer(
|
||||
inline.data,
|
||||
inline.contentType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inline.placeholder,
|
||||
});
|
||||
} catch {
|
||||
// Ignore decode failures and continue.
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!isUrlAllowed(candidate.url, allowHosts)) continue;
|
||||
try {
|
||||
const res = await fetchWithAuthFallback({
|
||||
url: candidate.url,
|
||||
tokenProvider: params.tokenProvider,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.byteLength > params.maxBytes) continue;
|
||||
const mime = await detectMime({
|
||||
buffer,
|
||||
headerMime: res.headers.get("content-type"),
|
||||
filePath: candidate.fileHint ?? candidate.url,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
buffer,
|
||||
mime ?? candidate.contentTypeHint,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: candidate.placeholder,
|
||||
});
|
||||
} catch {
|
||||
// Ignore download failures and continue.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
248
src/msteams/attachments/graph.ts
Normal file
248
src/msteams/attachments/graph.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { detectMime } from "../../media/mime.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||
import {
|
||||
GRAPH_ROOT,
|
||||
isRecord,
|
||||
normalizeContentType,
|
||||
resolveAllowedHosts,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
MSTeamsAttachmentLike,
|
||||
MSTeamsGraphMediaResult,
|
||||
MSTeamsInboundMedia,
|
||||
} from "./types.js";
|
||||
|
||||
type GraphHostedContent = {
|
||||
id?: string | null;
|
||||
contentType?: string | null;
|
||||
contentBytes?: string | null;
|
||||
};
|
||||
|
||||
type GraphAttachment = {
|
||||
id?: string | null;
|
||||
contentType?: string | null;
|
||||
contentUrl?: string | null;
|
||||
name?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
function readNestedString(
|
||||
value: unknown,
|
||||
keys: Array<string | number>,
|
||||
): string | undefined {
|
||||
let current: unknown = value;
|
||||
for (const key of keys) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[key as keyof typeof current];
|
||||
}
|
||||
return typeof current === "string" && current.trim()
|
||||
? current.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function buildMSTeamsGraphMessageUrls(params: {
|
||||
conversationType?: string | null;
|
||||
conversationId?: string | null;
|
||||
messageId?: string | null;
|
||||
replyToId?: string | null;
|
||||
conversationMessageId?: string | null;
|
||||
channelData?: unknown;
|
||||
}): string[] {
|
||||
const conversationType = params.conversationType?.trim().toLowerCase() ?? "";
|
||||
const messageIdCandidates = new Set<string>();
|
||||
const pushCandidate = (value: string | null | undefined) => {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (trimmed) messageIdCandidates.add(trimmed);
|
||||
};
|
||||
|
||||
pushCandidate(params.messageId);
|
||||
pushCandidate(params.conversationMessageId);
|
||||
pushCandidate(readNestedString(params.channelData, ["messageId"]));
|
||||
pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
|
||||
|
||||
const replyToId =
|
||||
typeof params.replyToId === "string" ? params.replyToId.trim() : "";
|
||||
|
||||
if (conversationType === "channel") {
|
||||
const teamId =
|
||||
readNestedString(params.channelData, ["team", "id"]) ??
|
||||
readNestedString(params.channelData, ["teamId"]);
|
||||
const channelId =
|
||||
readNestedString(params.channelData, ["channel", "id"]) ??
|
||||
readNestedString(params.channelData, ["channelId"]) ??
|
||||
readNestedString(params.channelData, ["teamsChannelId"]);
|
||||
if (!teamId || !channelId) return [];
|
||||
const urls: string[] = [];
|
||||
if (replyToId) {
|
||||
for (const candidate of messageIdCandidates) {
|
||||
if (candidate === replyToId) continue;
|
||||
urls.push(
|
||||
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (messageIdCandidates.size === 0 && replyToId)
|
||||
messageIdCandidates.add(replyToId);
|
||||
for (const candidate of messageIdCandidates) {
|
||||
urls.push(
|
||||
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
|
||||
);
|
||||
}
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
const chatId =
|
||||
params.conversationId?.trim() ||
|
||||
readNestedString(params.channelData, ["chatId"]);
|
||||
if (!chatId) return [];
|
||||
if (messageIdCandidates.size === 0 && replyToId)
|
||||
messageIdCandidates.add(replyToId);
|
||||
const urls = Array.from(messageIdCandidates).map(
|
||||
(candidate) =>
|
||||
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
|
||||
);
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
async function fetchGraphCollection<T>(params: {
|
||||
url: string;
|
||||
accessToken: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<{ status: number; items: T[] }> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const res = await fetchFn(params.url, {
|
||||
headers: { Authorization: `Bearer ${params.accessToken}` },
|
||||
});
|
||||
const status = res.status;
|
||||
if (!res.ok) return { status, items: [] };
|
||||
try {
|
||||
const data = (await res.json()) as { value?: T[] };
|
||||
return { status, items: Array.isArray(data.value) ? data.value : [] };
|
||||
} catch {
|
||||
return { status, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
|
||||
let content: unknown = att.content;
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
content = JSON.parse(content);
|
||||
} catch {
|
||||
// Keep as raw string if it's not JSON.
|
||||
}
|
||||
}
|
||||
return {
|
||||
contentType: normalizeContentType(att.contentType) ?? undefined,
|
||||
contentUrl: att.contentUrl ?? undefined,
|
||||
name: att.name ?? undefined,
|
||||
thumbnailUrl: att.thumbnailUrl ?? undefined,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadGraphHostedImages(params: {
|
||||
accessToken: string;
|
||||
messageUrl: string;
|
||||
maxBytes: number;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
|
||||
const hosted = await fetchGraphCollection<GraphHostedContent>({
|
||||
url: `${params.messageUrl}/hostedContents`,
|
||||
accessToken: params.accessToken,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
if (hosted.items.length === 0) {
|
||||
return { media: [], status: hosted.status, count: 0 };
|
||||
}
|
||||
|
||||
const out: MSTeamsInboundMedia[] = [];
|
||||
for (const item of hosted.items) {
|
||||
const contentBytes =
|
||||
typeof item.contentBytes === "string" ? item.contentBytes : "";
|
||||
if (!contentBytes) continue;
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = Buffer.from(contentBytes, "base64");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (buffer.byteLength > params.maxBytes) continue;
|
||||
const mime = await detectMime({
|
||||
buffer,
|
||||
headerMime: item.contentType ?? undefined,
|
||||
});
|
||||
if (mime && !mime.startsWith("image/")) continue;
|
||||
try {
|
||||
const saved = await saveMediaBuffer(
|
||||
buffer,
|
||||
mime ?? item.contentType ?? undefined,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:image>",
|
||||
});
|
||||
} catch {
|
||||
// Ignore save failures.
|
||||
}
|
||||
}
|
||||
|
||||
return { media: out, status: hosted.status, count: hosted.items.length };
|
||||
}
|
||||
|
||||
export async function downloadMSTeamsGraphMedia(params: {
|
||||
messageUrl?: string | null;
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
maxBytes: number;
|
||||
allowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<MSTeamsGraphMediaResult> {
|
||||
if (!params.messageUrl || !params.tokenProvider) return { media: [] };
|
||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||
const messageUrl = params.messageUrl;
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await params.tokenProvider.getAccessToken(
|
||||
"https://graph.microsoft.com/.default",
|
||||
);
|
||||
} catch {
|
||||
return { media: [], messageUrl, tokenError: true };
|
||||
}
|
||||
|
||||
const hosted = await downloadGraphHostedImages({
|
||||
accessToken,
|
||||
messageUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
const attachments = await fetchGraphCollection<GraphAttachment>({
|
||||
url: `${messageUrl}/attachments`,
|
||||
accessToken,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
|
||||
const attachmentMedia = await downloadMSTeamsImageAttachments({
|
||||
attachments: normalizedAttachments,
|
||||
maxBytes: params.maxBytes,
|
||||
tokenProvider: params.tokenProvider,
|
||||
allowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
return {
|
||||
media: [...hosted.media, ...attachmentMedia],
|
||||
hostedCount: hosted.count,
|
||||
attachmentCount: attachments.items.length,
|
||||
hostedStatus: hosted.status,
|
||||
attachmentStatus: attachments.status,
|
||||
messageUrl,
|
||||
};
|
||||
}
|
||||
79
src/msteams/attachments/html.ts
Normal file
79
src/msteams/attachments/html.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
ATTACHMENT_TAG_RE,
|
||||
extractHtmlFromAttachment,
|
||||
extractInlineImageCandidates,
|
||||
IMG_SRC_RE,
|
||||
isLikelyImageAttachment,
|
||||
safeHostForUrl,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAttachmentLike,
|
||||
MSTeamsHtmlAttachmentSummary,
|
||||
} from "./types.js";
|
||||
|
||||
export function summarizeMSTeamsHtmlAttachments(
|
||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||
): MSTeamsHtmlAttachmentSummary | undefined {
|
||||
const list = Array.isArray(attachments) ? attachments : [];
|
||||
if (list.length === 0) return undefined;
|
||||
let htmlAttachments = 0;
|
||||
let imgTags = 0;
|
||||
let dataImages = 0;
|
||||
let cidImages = 0;
|
||||
const srcHosts = new Set<string>();
|
||||
let attachmentTags = 0;
|
||||
const attachmentIds = new Set<string>();
|
||||
|
||||
for (const att of list) {
|
||||
const html = extractHtmlFromAttachment(att);
|
||||
if (!html) continue;
|
||||
htmlAttachments += 1;
|
||||
IMG_SRC_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
|
||||
while (match) {
|
||||
imgTags += 1;
|
||||
const src = match[1]?.trim();
|
||||
if (src) {
|
||||
if (src.startsWith("data:")) dataImages += 1;
|
||||
else if (src.startsWith("cid:")) cidImages += 1;
|
||||
else srcHosts.add(safeHostForUrl(src));
|
||||
}
|
||||
match = IMG_SRC_RE.exec(html);
|
||||
}
|
||||
|
||||
ATTACHMENT_TAG_RE.lastIndex = 0;
|
||||
let attachmentMatch: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html);
|
||||
while (attachmentMatch) {
|
||||
attachmentTags += 1;
|
||||
const id = attachmentMatch[1]?.trim();
|
||||
if (id) attachmentIds.add(id);
|
||||
attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
|
||||
}
|
||||
}
|
||||
|
||||
if (htmlAttachments === 0) return undefined;
|
||||
return {
|
||||
htmlAttachments,
|
||||
imgTags,
|
||||
dataImages,
|
||||
cidImages,
|
||||
srcHosts: Array.from(srcHosts).slice(0, 5),
|
||||
attachmentTags,
|
||||
attachmentIds: Array.from(attachmentIds).slice(0, 5),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMSTeamsAttachmentPlaceholder(
|
||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||
): string {
|
||||
const list = Array.isArray(attachments) ? attachments : [];
|
||||
if (list.length === 0) return "";
|
||||
const imageCount = list.filter(isLikelyImageAttachment).length;
|
||||
const inlineCount = extractInlineImageCandidates(list).length;
|
||||
const totalImages = imageCount + inlineCount;
|
||||
if (totalImages > 0) {
|
||||
return `<media:image>${totalImages > 1 ? ` (${totalImages} images)` : ""}`;
|
||||
}
|
||||
const count = list.length;
|
||||
return `<media:document>${count > 1 ? ` (${count} files)` : ""}`;
|
||||
}
|
||||
22
src/msteams/attachments/payload.ts
Normal file
22
src/msteams/attachments/payload.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export function buildMSTeamsMediaPayload(
|
||||
mediaList: Array<{ path: string; contentType?: string }>,
|
||||
): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
const first = mediaList[0];
|
||||
const mediaPaths = mediaList.map((media) => media.path);
|
||||
const mediaTypes = mediaList.map((media) => media.contentType ?? "");
|
||||
return {
|
||||
MediaPath: first?.path,
|
||||
MediaType: first?.contentType,
|
||||
MediaUrl: first?.path,
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaTypes: mediaPaths.length > 0 ? mediaTypes : undefined,
|
||||
};
|
||||
}
|
||||
210
src/msteams/attachments/shared.ts
Normal file
210
src/msteams/attachments/shared.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { MSTeamsAttachmentLike } from "./types.js";
|
||||
|
||||
type InlineImageCandidate =
|
||||
| {
|
||||
kind: "data";
|
||||
data: Buffer;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
}
|
||||
| {
|
||||
kind: "url";
|
||||
url: string;
|
||||
contentType?: string;
|
||||
fileHint?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
|
||||
|
||||
export const IMG_SRC_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||
export const ATTACHMENT_TAG_RE = /<attachment[^>]+id=["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
|
||||
"graph.microsoft.com",
|
||||
"graph.microsoft.us",
|
||||
"graph.microsoft.de",
|
||||
"graph.microsoft.cn",
|
||||
"sharepoint.com",
|
||||
"sharepoint.us",
|
||||
"sharepoint.de",
|
||||
"sharepoint.cn",
|
||||
"sharepoint-df.com",
|
||||
"1drv.ms",
|
||||
"onedrive.com",
|
||||
"teams.microsoft.com",
|
||||
"teams.cdn.office.net",
|
||||
"statics.teams.cdn.office.net",
|
||||
"office.com",
|
||||
"office.net",
|
||||
] as const;
|
||||
|
||||
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeContentType(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function inferPlaceholder(params: {
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
fileType?: string;
|
||||
}): string {
|
||||
const mime = params.contentType?.toLowerCase() ?? "";
|
||||
const name = params.fileName?.toLowerCase() ?? "";
|
||||
const fileType = params.fileType?.toLowerCase() ?? "";
|
||||
|
||||
const looksLikeImage =
|
||||
mime.startsWith("image/") ||
|
||||
IMAGE_EXT_RE.test(name) ||
|
||||
IMAGE_EXT_RE.test(`x.${fileType}`);
|
||||
|
||||
return looksLikeImage ? "<media:image>" : "<media:document>";
|
||||
}
|
||||
|
||||
export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||
const contentType = normalizeContentType(att.contentType) ?? "";
|
||||
const name = typeof att.name === "string" ? att.name : "";
|
||||
if (contentType.startsWith("image/")) return true;
|
||||
if (IMAGE_EXT_RE.test(name)) return true;
|
||||
|
||||
if (
|
||||
contentType === "application/vnd.microsoft.teams.file.download.info" &&
|
||||
isRecord(att.content)
|
||||
) {
|
||||
const fileType =
|
||||
typeof att.content.fileType === "string" ? att.content.fileType : "";
|
||||
if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) return true;
|
||||
const fileName =
|
||||
typeof att.content.fileName === "string" ? att.content.fileName : "";
|
||||
if (fileName && IMAGE_EXT_RE.test(fileName)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||
const contentType = normalizeContentType(att.contentType) ?? "";
|
||||
return contentType.startsWith("text/html");
|
||||
}
|
||||
|
||||
export function extractHtmlFromAttachment(
|
||||
att: MSTeamsAttachmentLike,
|
||||
): string | undefined {
|
||||
if (!isHtmlAttachment(att)) return undefined;
|
||||
if (typeof att.content === "string") return att.content;
|
||||
if (!isRecord(att.content)) return undefined;
|
||||
const text =
|
||||
typeof att.content.text === "string"
|
||||
? att.content.text
|
||||
: typeof att.content.body === "string"
|
||||
? att.content.body
|
||||
: typeof att.content.content === "string"
|
||||
? att.content.content
|
||||
: undefined;
|
||||
return text;
|
||||
}
|
||||
|
||||
function decodeDataImage(src: string): InlineImageCandidate | null {
|
||||
const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
|
||||
if (!match) return null;
|
||||
const contentType = match[1]?.toLowerCase();
|
||||
const isBase64 = Boolean(match[2]);
|
||||
if (!isBase64) return null;
|
||||
const payload = match[3] ?? "";
|
||||
if (!payload) return null;
|
||||
try {
|
||||
const data = Buffer.from(payload, "base64");
|
||||
return { kind: "data", data, contentType, placeholder: "<media:image>" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fileHintFromUrl(src: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
const name = url.pathname.split("/").pop();
|
||||
return name || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractInlineImageCandidates(
|
||||
attachments: MSTeamsAttachmentLike[],
|
||||
): InlineImageCandidate[] {
|
||||
const out: InlineImageCandidate[] = [];
|
||||
for (const att of attachments) {
|
||||
const html = extractHtmlFromAttachment(att);
|
||||
if (!html) continue;
|
||||
IMG_SRC_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
|
||||
while (match) {
|
||||
const src = match[1]?.trim();
|
||||
if (src && !src.startsWith("cid:")) {
|
||||
if (src.startsWith("data:")) {
|
||||
const decoded = decodeDataImage(src);
|
||||
if (decoded) out.push(decoded);
|
||||
} else {
|
||||
out.push({
|
||||
kind: "url",
|
||||
url: src,
|
||||
fileHint: fileHintFromUrl(src),
|
||||
placeholder: "<media:image>",
|
||||
});
|
||||
}
|
||||
}
|
||||
match = IMG_SRC_RE.exec(html);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function safeHostForUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return "invalid-url";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAllowHost(value: string): string {
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed === "*") return "*";
|
||||
return trimmed.replace(/^\*\.?/, "");
|
||||
}
|
||||
|
||||
export function resolveAllowedHosts(input?: string[]): string[] {
|
||||
if (!Array.isArray(input) || input.length === 0) {
|
||||
return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
|
||||
}
|
||||
const normalized = input.map(normalizeAllowHost).filter(Boolean);
|
||||
if (normalized.includes("*")) return ["*"];
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isHostAllowed(host: string, allowlist: string[]): boolean {
|
||||
if (allowlist.includes("*")) return true;
|
||||
const normalized = host.toLowerCase();
|
||||
return allowlist.some(
|
||||
(entry) => normalized === entry || normalized.endsWith(`.${entry}`),
|
||||
);
|
||||
}
|
||||
|
||||
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
return isHostAllowed(parsed.hostname, allowlist);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
37
src/msteams/attachments/types.ts
Normal file
37
src/msteams/attachments/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type MSTeamsAttachmentLike = {
|
||||
contentType?: string | null;
|
||||
contentUrl?: string | null;
|
||||
name?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
export type MSTeamsAccessTokenProvider = {
|
||||
getAccessToken: (scope: string) => Promise<string>;
|
||||
};
|
||||
|
||||
export type MSTeamsInboundMedia = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export type MSTeamsHtmlAttachmentSummary = {
|
||||
htmlAttachments: number;
|
||||
imgTags: number;
|
||||
dataImages: number;
|
||||
cidImages: number;
|
||||
srcHosts: string[];
|
||||
attachmentTags: number;
|
||||
attachmentIds: string[];
|
||||
};
|
||||
|
||||
export type MSTeamsGraphMediaResult = {
|
||||
media: MSTeamsInboundMedia[];
|
||||
hostedCount?: number;
|
||||
attachmentCount?: number;
|
||||
hostedStatus?: number;
|
||||
attachmentStatus?: number;
|
||||
messageUrl?: string;
|
||||
tokenError?: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user