UNPKG

@gguf/claw

Version:

WhatsApp gateway CLI (Baileys web) with Pi RPC agent

354 lines (330 loc) 11.4 kB
import type { MSTeamsAccessTokenProvider, MSTeamsAttachmentLike, MSTeamsGraphMediaResult, MSTeamsInboundMedia, } from "./types.js"; import { getMSTeamsRuntime } from "../runtime.js"; import { downloadMSTeamsAttachments } from "./download.js"; import { GRAPH_ROOT, inferPlaceholder, isRecord, normalizeContentType, resolveAllowedHosts, } from "./shared.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, }; } /** * Download all hosted content from a Teams message (images, documents, etc.). * Renamed from downloadGraphHostedImages to support all file types. */ async function downloadGraphHostedContent(params: { accessToken: string; messageUrl: string; maxBytes: number; fetchFn?: typeof fetch; preserveFilenames?: boolean; }): 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 getMSTeamsRuntime().media.detectMime({ buffer, headerMime: item.contentType ?? undefined, }); // Download any file type, not just images try { const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( buffer, mime ?? item.contentType ?? undefined, "inbound", params.maxBytes, ); out.push({ path: saved.path, contentType: saved.contentType, placeholder: inferPlaceholder({ contentType: saved.contentType }), }); } 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[]; authAllowHosts?: string[]; fetchFn?: typeof fetch; /** When true, embeds original filename in stored path for later extraction. */ preserveFilenames?: boolean; }): 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"); } catch { return { media: [], messageUrl, tokenError: true }; } // Fetch the full message to get SharePoint file attachments (for group chats) const fetchFn = params.fetchFn ?? fetch; const sharePointMedia: MSTeamsInboundMedia[] = []; const downloadedReferenceUrls = new Set<string>(); try { const msgRes = await fetchFn(messageUrl, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (msgRes.ok) { const msgData = (await msgRes.json()) as { body?: { content?: string; contentType?: string }; attachments?: Array<{ id?: string; contentUrl?: string; contentType?: string; name?: string; }>; }; // Extract SharePoint file attachments (contentType: "reference") // Download any file type, not just images const spAttachments = (msgData.attachments ?? []).filter( (a) => a.contentType === "reference" && a.contentUrl && a.name, ); for (const att of spAttachments) { const name = att.name ?? "file"; try { // SharePoint URLs need to be accessed via Graph shares API const shareUrl = att.contentUrl!; const encodedUrl = Buffer.from(shareUrl).toString("base64url"); const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`; const spRes = await fetchFn(sharesUrl, { headers: { Authorization: `Bearer ${accessToken}` }, redirect: "follow", }); if (spRes.ok) { const buffer = Buffer.from(await spRes.arrayBuffer()); if (buffer.byteLength <= params.maxBytes) { const mime = await getMSTeamsRuntime().media.detectMime({ buffer, headerMime: spRes.headers.get("content-type") ?? undefined, filePath: name, }); const originalFilename = params.preserveFilenames ? name : undefined; const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( buffer, mime ?? "application/octet-stream", "inbound", params.maxBytes, originalFilename, ); sharePointMedia.push({ path: saved.path, contentType: saved.contentType, placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }), }); downloadedReferenceUrls.add(shareUrl); } } } catch { // Ignore SharePoint download failures. } } } } catch { // Ignore message fetch failures. } const hosted = await downloadGraphHostedContent({ accessToken, messageUrl, maxBytes: params.maxBytes, fetchFn: params.fetchFn, preserveFilenames: params.preserveFilenames, }); const attachments = await fetchGraphCollection<GraphAttachment>({ url: `${messageUrl}/attachments`, accessToken, fetchFn: params.fetchFn, }); const normalizedAttachments = attachments.items.map(normalizeGraphAttachment); const filteredAttachments = sharePointMedia.length > 0 ? normalizedAttachments.filter((att) => { const contentType = att.contentType?.toLowerCase(); if (contentType !== "reference") { return true; } const url = typeof att.contentUrl === "string" ? att.contentUrl : ""; if (!url) { return true; } return !downloadedReferenceUrls.has(url); }) : normalizedAttachments; const attachmentMedia = await downloadMSTeamsAttachments({ attachments: filteredAttachments, maxBytes: params.maxBytes, tokenProvider: params.tokenProvider, allowHosts, authAllowHosts: params.authAllowHosts, fetchFn: params.fetchFn, preserveFilenames: params.preserveFilenames, }); return { media: [...sharePointMedia, ...hosted.media, ...attachmentMedia], hostedCount: hosted.count, attachmentCount: filteredAttachments.length + sharePointMedia.length, hostedStatus: hosted.status, attachmentStatus: attachments.status, messageUrl, }; }