diff --git a/packages/agents-activity/src/conversation/channels.ts b/packages/agents-activity/src/conversation/channels.ts index 2108c282..816cac79 100644 --- a/packages/agents-activity/src/conversation/channels.ts +++ b/packages/agents-activity/src/conversation/channels.ts @@ -61,6 +61,12 @@ export enum Channels { */ Msteams = 'msteams', + /** + * M365 Copilot Teams Subchannel. + */ + M365CopilotSubChannel = 'COPILOT', + M365Copilot = `${Msteams}:${M365CopilotSubChannel}`, + /** * Represents the Omnichannel. */ diff --git a/packages/agents-hosting-extensions-teams/src/teamsAttachmentDownloader.ts b/packages/agents-hosting-extensions-teams/src/teamsAttachmentDownloader.ts index b1167079..25aed971 100644 --- a/packages/agents-hosting-extensions-teams/src/teamsAttachmentDownloader.ts +++ b/packages/agents-hosting-extensions-teams/src/teamsAttachmentDownloader.ts @@ -3,90 +3,11 @@ * Licensed under the MIT License. */ -import { Attachment } from '@microsoft/agents-activity' -import { ConnectorClient, InputFile, InputFileDownloader, TurnContext, TurnState } from '@microsoft/agents-hosting' -import axios, { AxiosInstance } from 'axios' -import { z } from 'zod' +import { TeamsAttachmentDownloader as AppTeamsAttachmentDownloader } from '@microsoft/agents-hosting' + /** + * @deprecated Use {@link TeamsAttachmentDownloader} from @microsoft/agents-hosting instead. * Downloads attachments from Teams using the bots access token. */ -export class TeamsAttachmentDownloader implements InputFileDownloader { - private _httpClient: AxiosInstance - private _stateKey: string - public constructor (stateKey: string = 'inputFiles') { - this._httpClient = axios.create() - this._stateKey = stateKey - } - - /** - * Download any files relative to the current user's input. - * @template TState - Type of the state object passed to the `TurnContext.turnState` method. - * @param {TurnContext} context Context for the current turn of conversation. - * @param {TState} state Application state for the current turn of conversation. - * @returns {Promise} Promise that resolves to an array of downloaded input files. - */ - public async downloadFiles (context: TurnContext): Promise { - // Filter out HTML attachments - const attachments = context.activity.attachments?.filter((a) => !a.contentType.startsWith('text/html')) - if (!attachments || attachments.length === 0) { - return Promise.resolve([]) - } - - const connectorClient : ConnectorClient = context.turnState.get('connectorClient') - this._httpClient.defaults.headers = connectorClient.axiosInstance.defaults.headers - - const files: InputFile[] = [] - for (const attachment of attachments) { - const file = await this.downloadFile(attachment) - if (file) { - files.push(file) - } - } - - return files - } - - /** - * @private - * @param {Attachment} attachment - Attachment to download. - * @param {string} accessToken - Access token to use for downloading. - * @returns {Promise} - Promise that resolves to the downloaded input file. - */ - private async downloadFile (attachment: Attachment): Promise { - let inputFile: InputFile | undefined - if (attachment.contentType === 'application/vnd.microsoft.teams.file.download.info') { - const contentSchema = z.object({ downloadUrl: z.string() }) - const contentValue = contentSchema.parse(attachment.content) - const response = await this._httpClient.get(contentValue.downloadUrl, { responseType: 'arraybuffer' }) - const content = Buffer.from(response.data, 'binary') - let contentType = attachment.contentType - if (contentType === 'image/*') { - contentType = 'image/png' - } - inputFile = { content, contentType, contentUrl: attachment.contentUrl } - } else if (attachment.contentType === 'image/*') { - const response = await this._httpClient.get(attachment.contentUrl!, { responseType: 'arraybuffer' }) - const content = Buffer.from(response.data, 'binary') - inputFile = { content, contentType: attachment.contentType, contentUrl: attachment.contentUrl } - } else { - inputFile = { - content: Buffer.from(attachment.content as ArrayBuffer), - contentType: attachment.contentType, - contentUrl: attachment.contentUrl - } - } - return inputFile - } - - /** - * Downloads files from the attachments in the current turn context and stores them in state. - * - * @param context The turn context containing the activity with attachments. - * @param state The turn state to store the files in. - * @returns A promise that resolves when the downloaded files are stored. - */ - public async downloadAndStoreFiles (context: TurnContext, state: TState): Promise { - const files = await this.downloadFiles(context) - state.setValue(this._stateKey, files) - } +export class TeamsAttachmentDownloader extends AppTeamsAttachmentDownloader { } diff --git a/packages/agents-hosting/src/app/index.ts b/packages/agents-hosting/src/app/index.ts index da9825ff..06e62829 100644 --- a/packages/agents-hosting/src/app/index.ts +++ b/packages/agents-hosting/src/app/index.ts @@ -18,3 +18,4 @@ export * from './extensions' export * from './adaptiveCards' export * from './streaming/streamingResponse' export * from './streaming/citation' +export * from './teamsAttachmentDownloader' diff --git a/packages/agents-hosting/src/app/teamsAttachmentDownloader.ts b/packages/agents-hosting/src/app/teamsAttachmentDownloader.ts new file mode 100644 index 00000000..afed1627 --- /dev/null +++ b/packages/agents-hosting/src/app/teamsAttachmentDownloader.ts @@ -0,0 +1,110 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Attachment, Channels } from '@microsoft/agents-activity' +import { debug } from '@microsoft/agents-activity/logger' +import { ConnectorClient } from '../connector-client' +import { InputFile, InputFileDownloader } from './inputFileDownloader' +import { TurnContext } from '../turnContext' +import { TurnState } from './turnState' +import axios, { AxiosInstance } from 'axios' +import { z } from 'zod' + +const logger = debug('agents:teamsAttachmentDownloader') + +/** + * Downloads attachments from Teams using the bots access token. + */ +export class TeamsAttachmentDownloader implements InputFileDownloader { + private _httpClient: AxiosInstance + private _stateKey: string + + public constructor (stateKey: string = 'inputFiles') { + this._httpClient = axios.create() + this._stateKey = stateKey + } + + /** + * Download any files relative to the current user's input. + * + * @param {TurnContext} context Context for the current turn of conversation. + * @returns {Promise} Promise that resolves to an array of downloaded input files. + */ + public async downloadFiles (context: TurnContext): Promise { + if (context.activity.channelId !== Channels.Msteams && context.activity.channelId !== Channels.M365Copilot) { + return Promise.resolve([]) + } + // Filter out HTML attachments + const attachments = context.activity.attachments?.filter((a) => a.contentType && !a.contentType.startsWith('text/html')) + if (!attachments || attachments.length === 0) { + return Promise.resolve([]) + } + + const connectorClient : ConnectorClient = context.turnState.get(context.adapter.ConnectorClientKey) + this._httpClient.defaults.headers = connectorClient.axiosInstance.defaults.headers + + const files: InputFile[] = [] + for (const attachment of attachments) { + const file = await this.downloadFile(attachment) + if (file) { + files.push(file) + } + } + + return files + } + + /** + * @private + * @param {Attachment} attachment - Attachment to download. + * @returns {Promise} - Promise that resolves to the downloaded input file. + */ + private async downloadFile (attachment: Attachment): Promise { + let inputFile: InputFile | undefined + + if (attachment.contentUrl && attachment.contentUrl.startsWith('https://')) { + try { + const contentSchema = z.object({ downloadUrl: z.string().url() }) + const parsed = contentSchema.safeParse(attachment.content) + const downloadUrl = parsed.success ? parsed.data.downloadUrl : attachment.contentUrl + const response = await this._httpClient.get(downloadUrl, { responseType: 'arraybuffer' }) + + const content = Buffer.from(response.data, 'binary') + const contentType = response.headers['content-type'] || 'application/octet-stream' + inputFile = { content, contentType, contentUrl: attachment.contentUrl } + } catch (error) { + logger.error(`Failed to download Teams attachment: ${error}`) + return undefined + } + } else { + if (!attachment.content) { + logger.error('Attachment missing content') + return undefined + } + if (!(attachment.content instanceof ArrayBuffer) && !Buffer.isBuffer(attachment.content)) { + logger.error('Attachment content is not ArrayBuffer or Buffer') + return undefined + } + inputFile = { + content: Buffer.from(attachment.content as ArrayBuffer), + contentType: attachment.contentType, + contentUrl: attachment.contentUrl + } + } + return inputFile + } + + /** + * Downloads files from the attachments in the current turn context and stores them in state. + * + * @param context The turn context containing the activity with attachments. + * @param state The turn state to store the files in. + * @returns A promise that resolves when the downloaded files are stored. + */ + public async downloadAndStoreFiles (context: TurnContext, state: TState): Promise { + const files = await this.downloadFiles(context) + state.setValue(this._stateKey, files) + } +} diff --git a/samples/teams/teamsAttachments.ts b/samples/teams/teamsAttachments.ts index 29dca008..60acffac 100644 --- a/samples/teams/teamsAttachments.ts +++ b/samples/teams/teamsAttachments.ts @@ -1,6 +1,5 @@ import { startServer } from '@microsoft/agents-hosting-express' -import { AgentApplication } from '@microsoft/agents-hosting' -import { TeamsAttachmentDownloader } from '@microsoft/agents-hosting-extensions-teams' +import { AgentApplication, TeamsAttachmentDownloader } from '@microsoft/agents-hosting' const storedFilesKey = 'storedFiles' as const @@ -9,7 +8,7 @@ const agent = new AgentApplication({ }) agent.onConversationUpdate('membersAdded', async (context) => { - await context.sendActivity('Welcome to the Attachment sample, send a message with an attachment to see the echo feature in action.') + await context.sendActivity('Welcome to the Attachment sample, send a message with an attachment to see the feature in action.') }) agent.onActivity('message', async (context, state) => {