image works in DM
This commit is contained in:
@@ -3,6 +3,7 @@ import type { Command } from "commander";
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { sendMessageDiscord } from "../discord/send.js";
|
import { sendMessageDiscord } from "../discord/send.js";
|
||||||
import { sendMessageIMessage } from "../imessage/send.js";
|
import { sendMessageIMessage } from "../imessage/send.js";
|
||||||
|
import { sendMessageMSTeams } from "../msteams/send.js";
|
||||||
import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js";
|
import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js";
|
||||||
import {
|
import {
|
||||||
approveProviderPairingCode,
|
approveProviderPairingCode,
|
||||||
@@ -21,6 +22,7 @@ const PROVIDERS: PairingProvider[] = [
|
|||||||
"discord",
|
"discord",
|
||||||
"slack",
|
"slack",
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
|
"msteams",
|
||||||
];
|
];
|
||||||
|
|
||||||
function parseProvider(raw: unknown): PairingProvider {
|
function parseProvider(raw: unknown): PairingProvider {
|
||||||
@@ -65,6 +67,11 @@ async function notifyApproved(provider: PairingProvider, id: string) {
|
|||||||
await sendMessageIMessage(id, message);
|
await sendMessageIMessage(id, message);
|
||||||
return;
|
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.
|
// WhatsApp: approval still works (store); notifying requires an active web session.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildMSTeamsAttachmentPlaceholder,
|
buildMSTeamsAttachmentPlaceholder,
|
||||||
|
buildMSTeamsGraphMessageUrls,
|
||||||
buildMSTeamsMediaPayload,
|
buildMSTeamsMediaPayload,
|
||||||
|
downloadMSTeamsGraphMedia,
|
||||||
downloadMSTeamsImageAttachments,
|
downloadMSTeamsImageAttachments,
|
||||||
} from "./attachments.js";
|
} from "./attachments.js";
|
||||||
|
|
||||||
@@ -70,6 +72,26 @@ describe("msteams attachments", () => {
|
|||||||
]),
|
]),
|
||||||
).toBe("<media:document> (2 files)");
|
).toBe("<media:document> (2 files)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts inline images in text/html attachments", () => {
|
||||||
|
expect(
|
||||||
|
buildMSTeamsAttachmentPlaceholder([
|
||||||
|
{
|
||||||
|
contentType: "text/html",
|
||||||
|
content: '<p>hi</p><img src="https://x/a.png" />',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toBe("<media:image>");
|
||||||
|
expect(
|
||||||
|
buildMSTeamsAttachmentPlaceholder([
|
||||||
|
{
|
||||||
|
contentType: "text/html",
|
||||||
|
content:
|
||||||
|
'<img src="https://x/a.png" /><img src="https://x/b.png" />',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toBe("<media:image> (2 images)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("downloadMSTeamsImageAttachments", () => {
|
describe("downloadMSTeamsImageAttachments", () => {
|
||||||
@@ -118,6 +140,45 @@ describe("msteams attachments", () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledWith("https://x/dl");
|
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: '<img src="https://x/inline.png" />',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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: `<img src="data:image/png;base64,${base64}" />`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
maxBytes: 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(media).toHaveLength(1);
|
||||||
|
expect(saveMediaBufferMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("retries with auth when the first request is unauthorized", async () => {
|
it("retries with auth when the first request is unauthorized", async () => {
|
||||||
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
||||||
const hasAuth = Boolean(
|
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", () => {
|
describe("buildMSTeamsMediaPayload", () => {
|
||||||
it("returns single and multi-file fields", () => {
|
it("returns single and multi-file fields", () => {
|
||||||
const payload = buildMSTeamsMediaPayload([
|
const payload = buildMSTeamsMediaPayload([
|
||||||
|
|||||||
@@ -26,8 +26,63 @@ export type MSTeamsInboundMedia = {
|
|||||||
placeholder: string;
|
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 IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
|
||||||
|
|
||||||
|
const IMG_SRC_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||||
|
const ATTACHMENT_TAG_RE = /<attachment[^>]+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<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -76,14 +131,387 @@ function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
|
|||||||
return false;
|
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: "<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
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 | 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: 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;
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}): Promise<MSTeamsGraphMediaResult> {
|
||||||
|
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<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,
|
||||||
|
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(
|
export function buildMSTeamsAttachmentPlaceholder(
|
||||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||||
): string {
|
): string {
|
||||||
const list = Array.isArray(attachments) ? attachments : [];
|
const list = Array.isArray(attachments) ? attachments : [];
|
||||||
if (list.length === 0) return "";
|
if (list.length === 0) return "";
|
||||||
const imageCount = list.filter(isLikelyImageAttachment).length;
|
const imageCount = list.filter(isLikelyImageAttachment).length;
|
||||||
if (imageCount > 0) {
|
const inlineCount = extractInlineImageCandidates(list).length;
|
||||||
return `<media:image>${imageCount > 1 ? ` (${imageCount} images)` : ""}`;
|
const totalImages = imageCount + inlineCount;
|
||||||
|
if (totalImages > 0) {
|
||||||
|
return `<media:image>${totalImages > 1 ? ` (${totalImages} images)` : ""}`;
|
||||||
}
|
}
|
||||||
const count = list.length;
|
const count = list.length;
|
||||||
return `<media:document>${count > 1 ? ` (${count} files)` : ""}`;
|
return `<media:document>${count > 1 ? ` (${count} files)` : ""}`;
|
||||||
@@ -206,14 +634,48 @@ export async function downloadMSTeamsImageAttachments(params: {
|
|||||||
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
||||||
if (list.length === 0) return [];
|
if (list.length === 0) return [];
|
||||||
|
|
||||||
const candidates = list
|
const candidates: DownloadCandidate[] = list
|
||||||
.filter(isLikelyImageAttachment)
|
.filter(isLikelyImageAttachment)
|
||||||
.map(resolveDownloadCandidate)
|
.map(resolveDownloadCandidate)
|
||||||
.filter(Boolean) as DownloadCandidate[];
|
.filter(Boolean) as DownloadCandidate[];
|
||||||
|
|
||||||
if (candidates.length === 0) return [];
|
const inlineCandidates = extractInlineImageCandidates(list);
|
||||||
|
const seenUrls = new Set<string>();
|
||||||
|
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[] = [];
|
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) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuthFallback({
|
const res = await fetchWithAuthFallback({
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ export function normalizeMSTeamsConversationId(raw: string): string {
|
|||||||
return raw.split(";")[0] ?? raw;
|
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(
|
export function parseMSTeamsActivityTimestamp(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): Date | undefined {
|
): Date | undefined {
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import { resolveAgentRoute } from "../routing/resolve-route.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
buildMSTeamsAttachmentPlaceholder,
|
buildMSTeamsAttachmentPlaceholder,
|
||||||
|
buildMSTeamsGraphMessageUrls,
|
||||||
buildMSTeamsMediaPayload,
|
buildMSTeamsMediaPayload,
|
||||||
|
downloadMSTeamsGraphMedia,
|
||||||
downloadMSTeamsImageAttachments,
|
downloadMSTeamsImageAttachments,
|
||||||
type MSTeamsAttachmentLike,
|
type MSTeamsAttachmentLike,
|
||||||
|
summarizeMSTeamsHtmlAttachments,
|
||||||
} from "./attachments.js";
|
} from "./attachments.js";
|
||||||
import type {
|
import type {
|
||||||
MSTeamsConversationStore,
|
MSTeamsConversationStore,
|
||||||
@@ -30,6 +33,7 @@ import {
|
|||||||
formatUnknownError,
|
formatUnknownError,
|
||||||
} from "./errors.js";
|
} from "./errors.js";
|
||||||
import {
|
import {
|
||||||
|
extractMSTeamsConversationMessageId,
|
||||||
normalizeMSTeamsConversationId,
|
normalizeMSTeamsConversationId,
|
||||||
parseMSTeamsActivityTimestamp,
|
parseMSTeamsActivityTimestamp,
|
||||||
stripMSTeamsMentionTags,
|
stripMSTeamsMentionTags,
|
||||||
@@ -139,6 +143,7 @@ export async function monitorMSTeamsProvider(
|
|||||||
)
|
)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
|
const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments);
|
||||||
|
|
||||||
log.info("received message", {
|
log.info("received message", {
|
||||||
rawText: rawText.slice(0, 50),
|
rawText: rawText.slice(0, 50),
|
||||||
@@ -148,6 +153,9 @@ export async function monitorMSTeamsProvider(
|
|||||||
from: from?.id,
|
from: from?.id,
|
||||||
conversation: conversation?.id,
|
conversation: conversation?.id,
|
||||||
});
|
});
|
||||||
|
if (htmlSummary) {
|
||||||
|
log.debug("html attachment summary", htmlSummary);
|
||||||
|
}
|
||||||
|
|
||||||
if (!rawBody) {
|
if (!rawBody) {
|
||||||
log.debug("skipping empty message after stripping mentions");
|
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
|
// Teams conversation.id may include ";messageid=..." suffix - strip it for session key
|
||||||
const rawConversationId = conversation?.id ?? "";
|
const rawConversationId = conversation?.id ?? "";
|
||||||
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
|
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
|
||||||
|
const conversationMessageId =
|
||||||
|
extractMSTeamsConversationMessageId(rawConversationId);
|
||||||
const conversationType = conversation?.conversationType ?? "personal";
|
const conversationType = conversation?.conversationType ?? "personal";
|
||||||
const isGroupChat =
|
const isGroupChat =
|
||||||
conversationType === "groupChat" || conversation?.isGroup === true;
|
conversationType === "groupChat" || conversation?.isGroup === true;
|
||||||
@@ -302,15 +312,81 @@ export async function monitorMSTeamsProvider(
|
|||||||
|
|
||||||
// Format the message body with envelope
|
// Format the message body with envelope
|
||||||
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
|
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
|
||||||
const mediaList = await downloadMSTeamsImageAttachments({
|
let mediaList = await downloadMSTeamsImageAttachments({
|
||||||
attachments,
|
attachments,
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
tokenProvider: {
|
tokenProvider: {
|
||||||
getAccessToken: (scope) => tokenProvider.getAccessToken(scope),
|
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) {
|
if (mediaList.length > 0) {
|
||||||
log.debug("downloaded image attachments", { count: mediaList.length });
|
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 mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||||
const body = formatAgentEnvelope({
|
const body = formatAgentEnvelope({
|
||||||
|
|||||||
91
tmp/2026-01-08-msteams-permissions-and-capabilities.md
Normal file
91
tmp/2026-01-08-msteams-permissions-and-capabilities.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
date: 2026-01-08
|
||||||
|
author: Onur <onur@textcortex.com>
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user