diff --git a/.changeset/curly-teams-attachments.md b/.changeset/curly-teams-attachments.md new file mode 100644 index 00000000..0d2297e5 --- /dev/null +++ b/.changeset/curly-teams-attachments.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/teams": patch +--- + +Authenticate connector-hosted inline attachments and parse Teams file download cards. diff --git a/apps/docs/content/adapters/official/teams.mdx b/apps/docs/content/adapters/official/teams.mdx index 45795eaf..6506c40a 100644 --- a/apps/docs/content/adapters/official/teams.mdx +++ b/apps/docs/content/adapters/official/teams.mdx @@ -223,6 +223,10 @@ The Teams SDK reads a generic `CLIENT_SECRET` environment variable and prefers i Incoming thread IDs preserve the Teams conversation type when the legacy ID-prefix heuristic would route it incorrectly. This keeps correctly classified IDs stable while selecting the buffered fallback for group chats whose IDs begin with `a:`. Thread IDs created by older adapter versions remain supported. +### Incoming attachments + +Incoming inline images and files are exposed through `message.attachments` with a lazy `fetchData()` method. The adapter authenticates connector-hosted inline attachments through the configured Teams bot client, while [Teams file download cards](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) use their direct download URL without the bot token. + ### User lookup The adapter supports looking up user profiles via the Microsoft Graph API. To enable it: diff --git a/packages/adapter-teams/README.md b/packages/adapter-teams/README.md index a60f4572..7ff6c0a2 100644 --- a/packages/adapter-teams/README.md +++ b/packages/adapter-teams/README.md @@ -209,6 +209,10 @@ TEAMS_API_URL=... # Optional, for GCC-High or sovereign-cloud deployments Incoming thread IDs preserve the Teams conversation type when the legacy ID-prefix heuristic would route it incorrectly. This keeps correctly classified IDs stable while selecting the buffered fallback for group chats whose IDs begin with `a:`. Thread IDs created by older adapter versions remain supported. +## Incoming attachments + +Incoming inline images and files are exposed through `message.attachments` with a lazy `fetchData()` method. The adapter authenticates connector-hosted inline attachments through the configured Teams bot client, while [Teams file download cards](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) use their direct download URL without the bot token. + ## User lookup (`getUser`) The adapter supports looking up user profiles via the Microsoft Graph API. To enable it: diff --git a/packages/adapter-teams/src/attachments.test.ts b/packages/adapter-teams/src/attachments.test.ts new file mode 100644 index 00000000..1fc597ce --- /dev/null +++ b/packages/adapter-teams/src/attachments.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createAnonymousAttachmentFetchData, + createTeamsAttachment, + rehydrateTeamsAttachment, + type TeamsAttachmentFetchers, +} from "./attachments"; + +const CONNECTOR_URL = "https://smba.trafficmanager.net/teams/"; + +function createFetchers( + fetchAuthenticated: TeamsAttachmentFetchers["fetchAuthenticated"] +): TeamsAttachmentFetchers { + return { + createAnonymousFetchData: createAnonymousAttachmentFetchData, + fetchAuthenticated, + }; +} + +describe("Teams attachments", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("downloads file cards anonymously and infers their MIME type", async () => { + const fetchAuthenticated = vi.fn(); + const anonymousFetch = vi.fn(async () => new Response("file contents")); + vi.stubGlobal("fetch", anonymousFetch); + const url = "https://smba.trafficmanager.net/teams/files/download"; + + const attachment = createTeamsAttachment( + { + contentType: "application/vnd.microsoft.teams.file.download.info", + content: { + downloadUrl: url, + fileType: ".png", + }, + name: "diagram.png", + }, + CONNECTOR_URL, + createFetchers(fetchAuthenticated) + ); + + expect(attachment).toMatchObject({ + type: "image", + url, + name: "diagram.png", + mimeType: "image/png", + fetchMetadata: { url }, + }); + await expect(attachment.fetchData?.()).resolves.toEqual( + Buffer.from("file contents") + ); + expect(anonymousFetch).toHaveBeenCalledWith(url); + expect(fetchAuthenticated).not.toHaveBeenCalled(); + }); + + it.each([ + [".pdf", "application/pdf"], + [".xls", "application/vnd.ms-excel"], + [ + ".xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ], + ])("infers the MIME type for %s file cards", (fileType, mimeType) => { + const attachment = createTeamsAttachment( + { + contentType: "application/vnd.microsoft.teams.file.download.info", + content: { + downloadUrl: "https://files.example.com/download", + fileType, + }, + name: `report${fileType}`, + }, + CONNECTOR_URL, + createFetchers(vi.fn()) + ); + + expect(attachment).toMatchObject({ + type: "file", + mimeType, + }); + }); + + it("keeps cross-origin and HTTP inline attachments compatible and anonymous", async () => { + const fetchAuthenticated = vi.fn(); + const anonymousFetch = vi.fn(async () => new Response("public image")); + vi.stubGlobal("fetch", anonymousFetch); + const urls = [ + "https://files.example.com/image.png", + "http://smba.trafficmanager.net/teams/image.png", + ]; + + const attachments = urls.map((contentUrl) => + createTeamsAttachment( + { + contentType: "image/png", + contentUrl, + name: "image.png", + }, + CONNECTOR_URL, + createFetchers(fetchAuthenticated) + ) + ); + + expect(attachments).toMatchObject( + urls.map((url) => ({ + type: "image", + url, + name: "image.png", + mimeType: "image/png", + fetchMetadata: { url }, + })) + ); + await Promise.all( + attachments.map((attachment) => attachment.fetchData?.()) + ); + expect(anonymousFetch).toHaveBeenCalledTimes(2); + expect(fetchAuthenticated).not.toHaveBeenCalled(); + }); + + it("rehydrates both retrieval modes and revalidates bot destinations", async () => { + const fetchAuthenticated = vi.fn(async () => + Buffer.from("rehydrated image") + ); + const anonymousFetch = vi.fn(async () => new Response("anonymous file")); + vi.stubGlobal("fetch", anonymousFetch); + const fetchers = createFetchers(fetchAuthenticated); + const url = + "https://smba.trafficmanager.net/teams/v3/attachments/image/views/original"; + const serialized = JSON.parse( + JSON.stringify({ + type: "image", + url, + fetchMetadata: { + url, + auth: "bot", + connectorOrigin: "https://smba.trafficmanager.net", + }, + }) + ) as Parameters[0]; + + const rehydrated = rehydrateTeamsAttachment(serialized, fetchers); + await expect(rehydrated.fetchData?.()).resolves.toEqual( + Buffer.from("rehydrated image") + ); + + for (const untrusted of [ + { + url: "https://files.example.com/image.png", + connectorOrigin: "https://smba.trafficmanager.net", + }, + { + url: "http://smba.trafficmanager.net/teams/image.png", + connectorOrigin: "https://smba.trafficmanager.net", + }, + { + url, + connectorOrigin: undefined, + }, + ]) { + const attachment = rehydrateTeamsAttachment( + { + type: "image", + url: untrusted.url, + fetchMetadata: { + url: untrusted.url, + auth: "bot", + ...(untrusted.connectorOrigin + ? { connectorOrigin: untrusted.connectorOrigin } + : {}), + }, + }, + fetchers + ); + await expect(attachment.fetchData?.()).rejects.toThrow( + "Refusing to send a bot token to an untrusted attachment URL" + ); + } + + const anonymousUrl = "https://files.example.com/report.pdf"; + const anonymous = rehydrateTeamsAttachment( + JSON.parse( + JSON.stringify({ + type: "file", + url: anonymousUrl, + mimeType: "application/pdf", + fetchMetadata: { url: anonymousUrl }, + }) + ) as Parameters[0], + fetchers + ); + await expect(anonymous.fetchData?.()).resolves.toEqual( + Buffer.from("anonymous file") + ); + + expect(fetchAuthenticated).toHaveBeenCalledTimes(1); + expect(anonymousFetch).toHaveBeenCalledWith(anonymousUrl); + }); +}); diff --git a/packages/adapter-teams/src/attachments.ts b/packages/adapter-teams/src/attachments.ts new file mode 100644 index 00000000..3ddd63b8 --- /dev/null +++ b/packages/adapter-teams/src/attachments.ts @@ -0,0 +1,211 @@ +import { NetworkError } from "@chat-adapter/shared"; +import type { Attachment } from "chat"; + +const FILE_DOWNLOAD_INFO_CONTENT_TYPE = + "application/vnd.microsoft.teams.file.download.info"; +const FILE_TYPE_PREFIX_PATTERN = /^\./; +const FILE_MIME_TYPES: Record = { + apng: "image/apng", + avif: "image/avif", + gif: "image/gif", + jpeg: "image/jpeg", + jpg: "image/jpeg", + pdf: "application/pdf", + png: "image/png", + svg: "image/svg+xml", + txt: "text/plain", + webp: "image/webp", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +}; + +type AttachmentRetrieval = + | { + mode: "anonymous"; + url: string; + } + | { + connectorOrigin: string; + mode: "bot"; + url: string; + } + | { + mode: "rejected"; + url: string; + }; + +export interface TeamsAttachmentFetchers { + createAnonymousFetchData: (url: string) => () => Promise; + fetchAuthenticated: (url: string) => Promise; +} + +export interface TeamsActivityAttachment { + content?: unknown; + contentType?: string; + contentUrl?: string; + name?: string; +} + +function inferFileMimeType(name?: string, fileType?: string): string { + const extension = (fileType || name?.split(".").pop() || "") + .toLowerCase() + .replace(FILE_TYPE_PREFIX_PATTERN, ""); + return FILE_MIME_TYPES[extension] ?? "application/octet-stream"; +} + +function getHttpsOrigin(url?: string): string | undefined { + if (!url) { + return; + } + + try { + const parsed = new URL(url); + return parsed.protocol === "https:" ? parsed.origin : undefined; + } catch { + return; + } +} + +function isTrustedBotAttachmentUrl( + url: string, + connectorOrigin: string +): boolean { + return getHttpsOrigin(url) === connectorOrigin; +} + +function createFetchDataFn( + retrieval: AttachmentRetrieval, + fetchers: TeamsAttachmentFetchers +): () => Promise { + if (retrieval.mode === "anonymous") { + return fetchers.createAnonymousFetchData(retrieval.url); + } + + return async () => { + if ( + retrieval.mode === "rejected" || + !isTrustedBotAttachmentUrl(retrieval.url, retrieval.connectorOrigin) + ) { + throw new NetworkError( + "teams", + "Refusing to send a bot token to an untrusted attachment URL" + ); + } + return fetchers.fetchAuthenticated(retrieval.url); + }; +} + +export function createAnonymousAttachmentFetchData( + url: string +): () => Promise { + return async () => { + const response = await fetch(url); + if (!response.ok) { + throw new NetworkError( + "teams", + `Failed to fetch file: ${response.status} ${response.statusText}` + ); + } + return Buffer.from(await response.arrayBuffer()); + }; +} + +function classifyAttachmentType( + mimeType: string | undefined +): Attachment["type"] { + if (mimeType?.startsWith("image/")) { + return "image"; + } + if (mimeType?.startsWith("video/")) { + return "video"; + } + if (mimeType?.startsWith("audio/")) { + return "audio"; + } + return "file"; +} + +export function createTeamsAttachment( + att: TeamsActivityAttachment, + serviceUrl: string | undefined, + fetchers: TeamsAttachmentFetchers +): Attachment { + const isFileDownload = att.contentType === FILE_DOWNLOAD_INFO_CONTENT_TYPE; + const fileContent = + isFileDownload && typeof att.content === "object" && att.content !== null + ? (att.content as { + downloadUrl?: unknown; + fileType?: unknown; + }) + : undefined; + const fileType = + typeof fileContent?.fileType === "string" + ? fileContent.fileType + : undefined; + let url = att.contentUrl; + if (isFileDownload) { + url = + typeof fileContent?.downloadUrl === "string" + ? fileContent.downloadUrl + : undefined; + } + const mimeType = isFileDownload + ? inferFileMimeType(att.name, fileType) + : att.contentType; + const connectorOrigin = getHttpsOrigin(serviceUrl); + const useBotAuth = + !isFileDownload && + url !== undefined && + connectorOrigin !== undefined && + isTrustedBotAttachmentUrl(url, connectorOrigin); + + let retrieval: AttachmentRetrieval | undefined; + if (url) { + retrieval = useBotAuth + ? { mode: "bot", url, connectorOrigin } + : { mode: "anonymous", url }; + } + let fetchMetadata: Record | undefined; + if (retrieval) { + fetchMetadata = + retrieval.mode === "bot" + ? { + url: retrieval.url, + auth: "bot", + connectorOrigin: retrieval.connectorOrigin, + } + : { url: retrieval.url }; + } + + return { + type: classifyAttachmentType(mimeType), + url, + name: att.name, + mimeType, + fetchMetadata, + fetchData: retrieval ? createFetchDataFn(retrieval, fetchers) : undefined, + }; +} + +export function rehydrateTeamsAttachment( + attachment: Attachment, + fetchers: TeamsAttachmentFetchers +): Attachment { + const url = attachment.fetchMetadata?.url ?? attachment.url; + if (!url) { + return attachment; + } + + const connectorOrigin = attachment.fetchMetadata?.connectorOrigin; + let retrieval: AttachmentRetrieval = { mode: "anonymous", url }; + if (attachment.fetchMetadata?.auth === "bot") { + retrieval = connectorOrigin + ? { mode: "bot", url, connectorOrigin } + : { mode: "rejected", url }; + } + + return { + ...attachment, + fetchData: createFetchDataFn(retrieval, fetchers), + }; +} diff --git a/packages/adapter-teams/src/index.test.ts b/packages/adapter-teams/src/index.test.ts index 368e54e2..abc8572b 100644 --- a/packages/adapter-teams/src/index.test.ts +++ b/packages/adapter-teams/src/index.test.ts @@ -10,6 +10,7 @@ import { import type { IStreamer } from "@microsoft/teams.apps"; import { ConsoleLogger, getEmoji } from "chat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TeamsActivityAttachment } from "./attachments"; import { createTeamsAdapter, TeamsAdapter, type TeamsThreadId } from "./index"; const WHITESPACE_START_PATTERN = /^\s/; @@ -33,6 +34,40 @@ class MockTeamsError extends Error { } const logger = new ConsoleLogger("error"); +const TEST_SERVICE_URL = "https://smba.trafficmanager.net/teams/"; + +class AttachmentTestAdapter extends TeamsAdapter { + createTestAttachment( + attachment: TeamsActivityAttachment, + serviceUrl?: string + ) { + return this.createAttachment(attachment, serviceUrl); + } +} + +function createAttachmentTestAdapter(): AttachmentTestAdapter { + return new AttachmentTestAdapter({ + appId: "test-app", + appPassword: "test", + logger, + }); +} + +function setAuthenticatedAttachmentGet( + adapter: TeamsAdapter, + get: ReturnType +): void { + const app = ( + adapter as unknown as { + app: { + api: { + http: { get: typeof get }; + }; + }; + } + ).app; + app.api.http.get = get; +} // encodeThreadId/decodeThreadId/isDM are pure — a minimally configured adapter // suffices for the shared thread-id contract (no init/network needed). @@ -193,6 +228,7 @@ describe("TeamsAdapter", () => { afterEach(() => { process.env = { ...savedEnv }; + vi.unstubAllGlobals(); }); it("should export createTeamsAdapter function", () => { @@ -736,6 +772,70 @@ describe("TeamsAdapter", () => { expect(message.attachments[3].type).toBe("file"); }); + it("authenticates trusted inline attachment downloads", async () => { + const adapter = createAttachmentTestAdapter(); + const authenticatedGet = vi.fn(async () => ({ + data: new TextEncoder().encode("protected image").buffer, + })); + setAuthenticatedAttachmentGet(adapter, authenticatedGet); + const anonymousFetch = vi.fn(); + vi.stubGlobal("fetch", anonymousFetch); + const url = + "https://smba.trafficmanager.net/teams/v3/attachments/image/views/original"; + + const attachment = adapter.createTestAttachment( + { + contentType: "image/png", + contentUrl: url, + name: "screenshot.png", + }, + TEST_SERVICE_URL + ); + + expect(attachment.fetchMetadata).toEqual({ + url, + auth: "bot", + connectorOrigin: "https://smba.trafficmanager.net", + }); + await expect(attachment.fetchData?.()).resolves.toEqual( + Buffer.from("protected image") + ); + expect(authenticatedGet).toHaveBeenCalledWith(url, { + maxRedirects: 0, + responseType: "arraybuffer", + }); + expect(anonymousFetch).not.toHaveBeenCalled(); + }); + + it("preserves anonymous fetch overrides during rehydration", async () => { + const overriddenFetch = vi.fn(async () => Buffer.from("overridden")); + + class CustomTeamsAdapter extends TeamsAdapter { + protected override createFetchDataFn( + url: string + ): () => Promise { + expect(url).toBe("https://files.example.com/report.pdf"); + return overriddenFetch; + } + } + + const adapter = new CustomTeamsAdapter({ + appId: "test-app", + appPassword: "test", + logger, + }); + const attachment = adapter.rehydrateAttachment({ + type: "file", + url: "https://files.example.com/report.pdf", + fetchMetadata: { url: "https://files.example.com/report.pdf" }, + }); + + await expect(attachment.fetchData?.()).resolves.toEqual( + Buffer.from("overridden") + ); + expect(overriddenFetch).toHaveBeenCalledOnce(); + }); + it("should set metadata.edited to false for new messages", () => { const adapter = createTeamsAdapter({ appId: "test-app", diff --git a/packages/adapter-teams/src/index.ts b/packages/adapter-teams/src/index.ts index 75ee1651..acbb8063 100644 --- a/packages/adapter-teams/src/index.ts +++ b/packages/adapter-teams/src/index.ts @@ -53,6 +53,12 @@ import { defaultEmojiResolver, Message, } from "chat"; +import { + createAnonymousAttachmentFetchData, + createTeamsAttachment, + rehydrateTeamsAttachment, + type TeamsActivityAttachment, +} from "./attachments"; import { BridgeHttpAdapter } from "./bridge-adapter"; import { AUTO_SUBMIT_ACTION_ID, cardToAdaptiveCard } from "./cards"; import { toAppOptions } from "./config"; @@ -850,56 +856,55 @@ export class TeamsAdapter implements Adapter { att.contentType !== "application/vnd.microsoft.card.adaptive" && !(att.contentType === "text/html" && !att.contentUrl) ) - .map((att) => this.createAttachment(att)), + .map((att) => this.createAttachment(att, activity.serviceUrl)), }); } - protected createAttachment(att: { - contentType?: string; - contentUrl?: string; - name?: string; - }): Attachment { - const url = att.contentUrl; - - let type: Attachment["type"] = "file"; - if (att.contentType?.startsWith("image/")) { - type = "image"; - } else if (att.contentType?.startsWith("video/")) { - type = "video"; - } else if (att.contentType?.startsWith("audio/")) { - type = "audio"; - } - - return { - type, - url, - name: att.name, - mimeType: att.contentType, - fetchMetadata: url ? { url } : undefined, - fetchData: url ? this.createFetchDataFn(url) : undefined, - }; + protected createAttachment( + att: TeamsActivityAttachment, + serviceUrl?: string + ): Attachment { + return createTeamsAttachment(att, serviceUrl, { + createAnonymousFetchData: (url) => this.createFetchDataFn(url), + fetchAuthenticated: (url) => this.fetchAuthenticatedAttachment(url), + }); } protected createFetchDataFn(url: string): () => Promise { - return async () => { - const response = await fetch(url); - if (!response.ok) { - throw new NetworkError( - "teams", - `Failed to fetch file: ${response.status} ${response.statusText}` - ); - } - const arrayBuffer = await response.arrayBuffer(); - return Buffer.from(arrayBuffer); - }; + return createAnonymousAttachmentFetchData(url); } - rehydrateAttachment(attachment: Attachment): Attachment { - const url = attachment.fetchMetadata?.url ?? attachment.url; - if (!url) { - return attachment; + private async fetchAuthenticatedAttachment(url: string): Promise { + try { + const response = await this.app.api.http.get(url, { + maxRedirects: 0, + responseType: "arraybuffer", + }); + return Buffer.from(response.data); + } catch (error) { + const status = + error && + typeof error === "object" && + "response" in error && + error.response && + typeof error.response === "object" && + "status" in error.response && + typeof error.response.status === "number" + ? error.response.status + : undefined; + throw new NetworkError( + "teams", + `Failed to fetch authenticated file${status ? `: ${status}` : ""}`, + error instanceof Error ? error : undefined + ); } - return { ...attachment, fetchData: this.createFetchDataFn(url) }; + } + + rehydrateAttachment(attachment: Attachment): Attachment { + return rehydrateTeamsAttachment(attachment, { + createAnonymousFetchData: (url) => this.createFetchDataFn(url), + fetchAuthenticated: (url) => this.fetchAuthenticatedAttachment(url), + }); } protected normalizeMentions(text: string): string {