Skip to content
6 changes: 6 additions & 0 deletions packages/agents-activity/src/conversation/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export enum Channels {
*/
Msteams = 'msteams',

/**
* M365 Copilot Teams Subchannel.
*/
M365CopilotSubChannel = 'COPILOT',
M365Copilot = `${Msteams}:${M365CopilotSubChannel}`,

/**
* Represents the Omnichannel.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TState extends TurnState = TurnState> implements InputFileDownloader<TState> {
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<InputFile[]>} Promise that resolves to an array of downloaded input files.
*/
public async downloadFiles (context: TurnContext): Promise<InputFile[]> {
// 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>('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<InputFile>} - Promise that resolves to the downloaded input file.
*/
private async downloadFile (attachment: Attachment): Promise<InputFile | undefined> {
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<void> {
const files = await this.downloadFiles(context)
state.setValue(this._stateKey, files)
}
export class TeamsAttachmentDownloader extends AppTeamsAttachmentDownloader {
}
1 change: 1 addition & 0 deletions packages/agents-hosting/src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from './extensions'
export * from './adaptiveCards'
export * from './streaming/streamingResponse'
export * from './streaming/citation'
export * from './teamsAttachmentDownloader'
113 changes: 113 additions & 0 deletions packages/agents-hosting/src/app/teamsAttachmentDownloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* 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<TState extends TurnState = TurnState> implements InputFileDownloader<TState> {
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<InputFile[]>} Promise that resolves to an array of downloaded input files.
*/
public async downloadFiles (context: TurnContext): Promise<InputFile[]> {
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<ConnectorClient>(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<InputFile>} - Promise that resolves to the downloaded input file.
*/
private async downloadFile (attachment: Attachment): Promise<InputFile | undefined> {
let inputFile: InputFile | undefined

if (attachment.contentUrl && (attachment.contentUrl.startsWith('https://') || attachment.contentUrl.startsWith('http://localhost'))) {
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')
let contentType = response.headers['content-type'] || 'application/octet-stream'
if (contentType.startsWith('image/')) {
contentType = 'image/png'
}
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<void> {
const files = await this.downloadFiles(context)
state.setValue(this._stateKey, files)
}
}
5 changes: 2 additions & 3 deletions samples/teams/teamsAttachments.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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) => {
Expand Down
Loading