230 lines
7.4 KiB
TypeScript
230 lines
7.4 KiB
TypeScript
import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk";
|
|
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,
|
|
};
|
|
}
|