diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index d671dc76c..be6ac1dc3 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -3,6 +3,7 @@ import type { Command } from "commander"; import { loadConfig } from "../config/config.js"; import { sendMessageDiscord } from "../discord/send.js"; import { sendMessageIMessage } from "../imessage/send.js"; +import { sendMessageMSTeams } from "../msteams/send.js"; import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js"; import { approveProviderPairingCode, @@ -21,6 +22,7 @@ const PROVIDERS: PairingProvider[] = [ "discord", "slack", "whatsapp", + "msteams", ]; function parseProvider(raw: unknown): PairingProvider { @@ -65,6 +67,11 @@ async function notifyApproved(provider: PairingProvider, id: string) { await sendMessageIMessage(id, message); return; } + if (provider === "msteams") { + const cfg = loadConfig(); + await sendMessageMSTeams({ cfg, to: id, text: message }); + return; + } // WhatsApp: approval still works (store); notifying requires an active web session. } diff --git a/src/msteams/attachments.test.ts b/src/msteams/attachments.test.ts index 1c690e580..d1f92a33e 100644 --- a/src/msteams/attachments.test.ts +++ b/src/msteams/attachments.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildMSTeamsAttachmentPlaceholder, + buildMSTeamsGraphMessageUrls, buildMSTeamsMediaPayload, + downloadMSTeamsGraphMedia, downloadMSTeamsImageAttachments, } from "./attachments.js"; @@ -70,6 +72,26 @@ describe("msteams attachments", () => { ]), ).toBe(" (2 files)"); }); + + it("counts inline images in text/html attachments", () => { + expect( + buildMSTeamsAttachmentPlaceholder([ + { + contentType: "text/html", + content: '

hi

', + }, + ]), + ).toBe(""); + expect( + buildMSTeamsAttachmentPlaceholder([ + { + contentType: "text/html", + content: + '', + }, + ]), + ).toBe(" (2 images)"); + }); }); describe("downloadMSTeamsImageAttachments", () => { @@ -118,6 +140,45 @@ describe("msteams attachments", () => { expect(fetchMock).toHaveBeenCalledWith("https://x/dl"); }); + it("downloads inline image URLs from html attachments", async () => { + const fetchMock = vi.fn(async () => { + return new Response(Buffer.from("png"), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + + const media = await downloadMSTeamsImageAttachments({ + attachments: [ + { + contentType: "text/html", + content: '', + }, + ], + maxBytes: 1024 * 1024, + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(media).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledWith("https://x/inline.png"); + }); + + it("stores inline data:image base64 payloads", async () => { + const base64 = Buffer.from("png").toString("base64"); + const media = await downloadMSTeamsImageAttachments({ + attachments: [ + { + contentType: "text/html", + content: ``, + }, + ], + maxBytes: 1024 * 1024, + }); + + expect(media).toHaveLength(1); + expect(saveMediaBufferMock).toHaveBeenCalled(); + }); + it("retries with auth when the first request is unauthorized", async () => { const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { const hasAuth = Boolean( @@ -163,6 +224,77 @@ describe("msteams attachments", () => { }); }); + describe("buildMSTeamsGraphMessageUrls", () => { + it("builds channel message urls", () => { + const urls = buildMSTeamsGraphMessageUrls({ + conversationType: "channel", + conversationId: "19:thread@thread.tacv2", + messageId: "123", + channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, + }); + expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123"); + }); + + it("builds channel reply urls when replyToId is present", () => { + const urls = buildMSTeamsGraphMessageUrls({ + conversationType: "channel", + messageId: "reply-id", + replyToId: "root-id", + channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, + }); + expect(urls[0]).toContain( + "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id", + ); + }); + + it("builds chat message urls", () => { + const urls = buildMSTeamsGraphMessageUrls({ + conversationType: "groupChat", + conversationId: "19:chat@thread.v2", + messageId: "456", + }); + expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456"); + }); + }); + + describe("downloadMSTeamsGraphMedia", () => { + it("downloads hostedContents images", async () => { + const base64 = Buffer.from("png").toString("base64"); + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith("/hostedContents")) { + return new Response( + JSON.stringify({ + value: [ + { + id: "1", + contentType: "image/png", + contentBytes: base64, + }, + ], + }), + { status: 200 }, + ); + } + if (url.endsWith("/attachments")) { + return new Response(JSON.stringify({ value: [] }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const media = await downloadMSTeamsGraphMedia({ + messageUrl: + "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + maxBytes: 1024 * 1024, + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(media.media).toHaveLength(1); + expect(fetchMock).toHaveBeenCalled(); + expect(saveMediaBufferMock).toHaveBeenCalled(); + }); + }); + describe("buildMSTeamsMediaPayload", () => { it("returns single and multi-file fields", () => { const payload = buildMSTeamsMediaPayload([ diff --git a/src/msteams/attachments.ts b/src/msteams/attachments.ts index 6c136b7d3..e426899a9 100644 --- a/src/msteams/attachments.ts +++ b/src/msteams/attachments.ts @@ -26,8 +26,63 @@ export type MSTeamsInboundMedia = { placeholder: string; }; +type InlineImageCandidate = + | { + kind: "data"; + data: Buffer; + contentType?: string; + placeholder: string; + } + | { + kind: "url"; + url: string; + contentType?: string; + fileHint?: string; + placeholder: string; + }; + const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i; +const IMG_SRC_RE = /]+src=["']([^"']+)["'][^>]*>/gi; +const ATTACHMENT_TAG_RE = /]+id=["']([^"']+)["'][^>]*>/gi; + +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; +}; + +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; +}; + +const GRAPH_ROOT = "https://graph.microsoft.com/v1.0"; + function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -76,14 +131,387 @@ function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean { return false; } +function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean { + const contentType = normalizeContentType(att.contentType) ?? ""; + return contentType.startsWith("text/html"); +} + +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: "", + }; + } 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; + } +} + +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: "", + }); + } + } + match = IMG_SRC_RE.exec(html); + } + } + return out; +} + +function safeHostForUrl(url: string): string { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return "invalid-url"; + } +} + +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(); + let attachmentTags = 0; + const attachmentIds = new Set(); + + 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; + match = ATTACHMENT_TAG_RE.exec(html); + while (match) { + attachmentTags += 1; + const id = match[1]?.trim(); + if (id) attachmentIds.add(id); + match = 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), + }; +} + +function readNestedString( + value: unknown, + keys: Array, +): 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(); + 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(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: 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({ + 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: "", + }); + } 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; + fetchFn?: typeof fetch; +}): Promise { + if (!params.messageUrl || !params.tokenProvider) { + return { media: [] }; + } + 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({ + 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, + fetchFn: params.fetchFn, + }); + + return { + media: [...hosted.media, ...attachmentMedia], + hostedCount: hosted.count, + attachmentCount: attachments.items.length, + hostedStatus: hosted.status, + attachmentStatus: attachments.status, + messageUrl, + }; +} + export function buildMSTeamsAttachmentPlaceholder( attachments: MSTeamsAttachmentLike[] | undefined, ): string { const list = Array.isArray(attachments) ? attachments : []; if (list.length === 0) return ""; const imageCount = list.filter(isLikelyImageAttachment).length; - if (imageCount > 0) { - return `${imageCount > 1 ? ` (${imageCount} images)` : ""}`; + const inlineCount = extractInlineImageCandidates(list).length; + const totalImages = imageCount + inlineCount; + if (totalImages > 0) { + return `${totalImages > 1 ? ` (${totalImages} images)` : ""}`; } const count = list.length; return `${count > 1 ? ` (${count} files)` : ""}`; @@ -206,14 +634,48 @@ export async function downloadMSTeamsImageAttachments(params: { const list = Array.isArray(params.attachments) ? params.attachments : []; if (list.length === 0) return []; - const candidates = list + const candidates: DownloadCandidate[] = list .filter(isLikelyImageAttachment) .map(resolveDownloadCandidate) .filter(Boolean) as DownloadCandidate[]; - if (candidates.length === 0) return []; + const inlineCandidates = extractInlineImageCandidates(list); + const seenUrls = new Set(); + for (const inline of inlineCandidates) { + if (inline.kind === "url") { + 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) { try { const res = await fetchWithAuthFallback({ diff --git a/src/msteams/inbound.ts b/src/msteams/inbound.ts index 5c37c68db..9f308deb8 100644 --- a/src/msteams/inbound.ts +++ b/src/msteams/inbound.ts @@ -10,6 +10,15 @@ export function normalizeMSTeamsConversationId(raw: string): string { return raw.split(";")[0] ?? raw; } +export function extractMSTeamsConversationMessageId( + raw: string, +): string | undefined { + if (!raw) return undefined; + const match = /(?:^|;)messageid=([^;]+)/i.exec(raw); + const value = match?.[1]?.trim() ?? ""; + return value || undefined; +} + export function parseMSTeamsActivityTimestamp( value: unknown, ): Date | undefined { diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts index e032f6d2e..7270d63be 100644 --- a/src/msteams/monitor.ts +++ b/src/msteams/monitor.ts @@ -15,9 +15,12 @@ import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { buildMSTeamsAttachmentPlaceholder, + buildMSTeamsGraphMessageUrls, buildMSTeamsMediaPayload, + downloadMSTeamsGraphMedia, downloadMSTeamsImageAttachments, type MSTeamsAttachmentLike, + summarizeMSTeamsHtmlAttachments, } from "./attachments.js"; import type { MSTeamsConversationStore, @@ -30,6 +33,7 @@ import { formatUnknownError, } from "./errors.js"; import { + extractMSTeamsConversationMessageId, normalizeMSTeamsConversationId, parseMSTeamsActivityTimestamp, stripMSTeamsMentionTags, @@ -139,6 +143,7 @@ export async function monitorMSTeamsProvider( ) .filter(Boolean) .slice(0, 3); + const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments); log.info("received message", { rawText: rawText.slice(0, 50), @@ -148,6 +153,9 @@ export async function monitorMSTeamsProvider( from: from?.id, conversation: conversation?.id, }); + if (htmlSummary) { + log.debug("html attachment summary", htmlSummary); + } if (!rawBody) { log.debug("skipping empty message after stripping mentions"); @@ -161,6 +169,8 @@ export async function monitorMSTeamsProvider( // Teams conversation.id may include ";messageid=..." suffix - strip it for session key const rawConversationId = conversation?.id ?? ""; const conversationId = normalizeMSTeamsConversationId(rawConversationId); + const conversationMessageId = + extractMSTeamsConversationMessageId(rawConversationId); const conversationType = conversation?.conversationType ?? "personal"; const isGroupChat = conversationType === "groupChat" || conversation?.isGroup === true; @@ -302,15 +312,81 @@ export async function monitorMSTeamsProvider( // Format the message body with envelope const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp); - const mediaList = await downloadMSTeamsImageAttachments({ + let mediaList = await downloadMSTeamsImageAttachments({ attachments, maxBytes: mediaMaxBytes, tokenProvider: { getAccessToken: (scope) => tokenProvider.getAccessToken(scope), }, }); + if (mediaList.length === 0) { + const onlyHtmlAttachments = + attachments.length > 0 && + attachments.every((att) => + String(att.contentType ?? "").startsWith("text/html"), + ); + if (onlyHtmlAttachments) { + const messageUrls = buildMSTeamsGraphMessageUrls({ + conversationType, + conversationId, + messageId: activity.id ?? undefined, + replyToId: activity.replyToId ?? undefined, + conversationMessageId, + channelData: activity.channelData, + }); + if (messageUrls.length === 0) { + log.debug("graph message url unavailable", { + conversationType, + hasChannelData: Boolean(activity.channelData), + messageId: activity.id ?? undefined, + replyToId: activity.replyToId ?? undefined, + }); + } else { + const attempts: Array<{ + url: string; + hostedStatus?: number; + attachmentStatus?: number; + hostedCount?: number; + attachmentCount?: number; + tokenError?: boolean; + }> = []; + for (const messageUrl of messageUrls) { + const graphMedia = await downloadMSTeamsGraphMedia({ + messageUrl, + tokenProvider: { + getAccessToken: (scope) => tokenProvider.getAccessToken(scope), + }, + maxBytes: mediaMaxBytes, + }); + attempts.push({ + url: messageUrl, + hostedStatus: graphMedia.hostedStatus, + attachmentStatus: graphMedia.attachmentStatus, + hostedCount: graphMedia.hostedCount, + attachmentCount: graphMedia.attachmentCount, + tokenError: graphMedia.tokenError, + }); + if (graphMedia.media.length > 0) { + mediaList = graphMedia.media; + break; + } + if (graphMedia.tokenError) break; + } + if (mediaList.length === 0) { + log.debug("graph media fetch empty", { attempts }); + } + } + } + } if (mediaList.length > 0) { log.debug("downloaded image attachments", { count: mediaList.length }); + } else if (htmlSummary?.imgTags) { + log.debug("inline images detected but none downloaded", { + imgTags: htmlSummary.imgTags, + srcHosts: htmlSummary.srcHosts, + dataImages: htmlSummary.dataImages, + cidImages: htmlSummary.cidImages, + }); } const mediaPayload = buildMSTeamsMediaPayload(mediaList); const body = formatAgentEnvelope({ diff --git a/tmp/2026-01-08-msteams-permissions-and-capabilities.md b/tmp/2026-01-08-msteams-permissions-and-capabilities.md new file mode 100644 index 000000000..f2357f1d5 --- /dev/null +++ b/tmp/2026-01-08-msteams-permissions-and-capabilities.md @@ -0,0 +1,91 @@ +--- +date: 2026-01-08 +author: Onur +title: MS Teams Permissions vs Capabilities (Clawdbot) +tags: [msteams, permissions, graph] +--- + +## Overview +This doc explains what Clawdbot can and cannot do in Microsoft Teams depending on **Teams resource-specific consent (RSC)** only versus **RSC + Microsoft Graph permissions**. It also outlines the exact steps needed to unlock each capability. + +## Current Teams RSC Permissions (Manifest) +These are the **existing resourceSpecific permissions** in the Teams app manifest (already in our ZIP): + +- `ChannelMessage.Read.Group` (Application) +- `ChannelMessage.Send.Group` (Application) +- `Member.Read.Group` (Application) +- `Owner.Read.Group` (Application) +- `ChannelSettings.Read.Group` (Application) +- `TeamMember.Read.Group` (Application) +- `TeamSettings.Read.Group` (Application) + +These only apply **inside the team where the app is installed**. + +## Capability Matrix + +### With **Teams RSC only** (app installed in a team, no Graph API permissions) +Works: +- Read channel message **text** content. +- Send channel message **text** content. +- Resolve basic sender identity (AAD/user id) and channel/team context. +- Use conversation references for proactive messages **only after** a user interacts. + +Does NOT work: +- **Image/file content** from channel or group chat messages (payload only includes HTML stub). +- Downloading attachments stored in SharePoint/OneDrive (requires Graph). +- Accessing messages outside the installed team. + +### With **Teams RSC + Microsoft Graph Application permissions** +Adds: +- Downloading **hosted contents** (images pasted into messages). +- Downloading **file attachments** stored in SharePoint/OneDrive. +- Full message/attachment lookup via Graph endpoints. + +Still **not** added automatically: +- 1:1 chat file support (requires separate Bot file flows if we want to support it). +- Cross-tenant access (blocked by tenant policies). + +## Required Steps by Capability + +### Phase 1 — Basic text-only channel bot +Goal: Read/send text messages in installed teams. + +Steps: +1. **Teams app manifest** includes the RSC permissions listed above. +2. Admin or user installs the app into a specific team. +3. Bot receives text-only channel message payloads. + +Expected behavior: +- Text is visible to the bot. +- Image/file attachments are **not** available (only HTML stub). + +### Phase 2 — Image and file ingestion (Graph enabled) +Goal: Download images/files from Teams messages. + +Steps: +1. In **Entra ID (Azure AD)** app registration for the bot, add **Microsoft Graph Application permissions**: + - For channel attachments: `ChannelMessage.Read.All` + - For chat/group attachments: `Chat.Read.All` (or `ChatMessage.Read.All`) +2. **Grant admin consent** in the tenant. +3. Increment Teams app **manifest version** and re-upload. +4. **Reinstall the app in Teams** (remove + add) and **fully quit/reopen Teams** to clear cached app metadata. + +Expected behavior: +- Bot still receives HTML stubs in the webhook. +- Bot now fetches hosted contents and attachments via Graph and can access images. + +## Why Graph Is Required for Images +Teams stores images and files in Microsoft 365 storage (SharePoint/OneDrive). The Teams bot webhook **does not send file bytes**, only a message shell. To access the actual file, the app must call **Microsoft Graph** with sufficient permissions. + +If Graph tokens are unavailable (permissions missing or no admin consent), image downloads will always fail. + +## Validation Checklist +- [ ] Teams app installed in target team. +- [ ] Graph permissions added and admin consented. +- [ ] Teams app version incremented and reinstalled. +- [ ] Logs show successful Graph token acquisition. +- [ ] Logs show Graph hostedContent/attachments fetched (non-zero counts). + +## References +- Teams bot file handling (channel/group requires Graph): + - https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4