@microsoft/agents-hosting-teams
Version:
Microsoft 365 Agents SDK for JavaScript
579 lines (518 loc) • 19.9 kB
text/typescript
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { Activity, ActivityTypes, ConversationReference } from '@microsoft/agents-activity'
import { AgentApplication, AppRoute, Authorization, debug, RouteHandler, RouteSelector, TurnContext, TurnState } from '@microsoft/agents-hosting'
import { TeamsApplicationOptions } from './teamsApplicationOptions'
import { FileConsentCardResponse } from '../file/fileConsentCardResponse'
import { ChannelInfo } from '../channel-data/channelInfo'
import { TeamsInfo } from '../teamsInfo'
import { TeamDetails } from '../connector-client/teamDetails'
import { TeamsPagedMembersResult } from '../connector-client/teamsPagedMembersResult'
import { ReadReceiptInfo } from '../message-read-info/readReceipInfo'
import { parseValueAction, parseValueContinuation } from '../parsers'
import { AdaptiveCardsActions } from './adaptive-cards-actions'
import { MessageReactionEvents, Messages, TeamsMessageEvents } from './messages'
import { MessageExtensions } from './messaging-extension'
import { Meetings } from './meeting'
import { TaskModules } from './task'
import { TeamsConversationUpdateEvents } from './conversation-events'
const logger = debug('agents:teams-application')
/**
* Represents a Teams application that extends the AgentApplication class.
* Provides various functionalities for handling Teams-specific events, messages, and interactions.
* @template TState - The type of the turn state.
*/
export class TeamsApplication<TState extends TurnState> extends AgentApplication<TState> {
/**
* Options for configuring the Teams application.
*/
private readonly _teamsOptions: TeamsApplicationOptions<TState>
/**
* Routes for handling invoke activities.
*/
private readonly _invokeRoutes: AppRoute<TState>[] = []
/**
* Handles adaptive card actions.
*/
private readonly _adaptiveCards: AdaptiveCardsActions<TState>
/**
* Handles messages and message-related events.
*/
private readonly _messages: Messages<TState>
/**
* Handles messaging extensions.
*/
private readonly _messageExtensions: MessageExtensions<TState>
/**
* Handles meeting-related events and actions.
*/
private readonly _meetings: Meetings<TState>
/**
* Handles task modules.
*/
private readonly _taskModules: TaskModules<TState>
/**
* Manages Teams OAuth flow for authentication.
*/
private readonly _teamsAuthManager?: Authorization
/**
* Initializes a new instance of the TeamsApplication class.
* @param options - Partial options for configuring the Teams application.
*/
public constructor (options?: Partial<TeamsApplicationOptions<TState>>) {
super()
this._teamsOptions = {
...super.options,
removeRecipientMention:
options?.removeRecipientMention !== undefined ? options.removeRecipientMention : true,
taskModules: options?.taskModules
}
if (options?.storage && options?.authorization) {
this._teamsAuthManager = new Authorization(this.options.storage!, this.options.authorization!)
}
this._adaptiveCards = new AdaptiveCardsActions<TState>(this)
this._messages = new Messages<TState>(this)
this._messageExtensions = new MessageExtensions<TState>(this)
this._meetings = new Meetings<TState>(this)
this._taskModules = new TaskModules<TState>(this)
}
/**
* Gets the Teams application options.
*/
public get teamsOptions (): TeamsApplicationOptions<TState> {
return this._teamsOptions
}
/**
* Gets the task modules handler.
*/
public get taskModules (): TaskModules<TState> {
return this._taskModules
}
/**
* Gets the adaptive cards actions handler.
*/
public get adaptiveCards (): AdaptiveCardsActions<TState> {
return this._adaptiveCards
}
/**
* Gets the messages handler.
*/
public get messages (): Messages<TState> {
return this._messages
}
/**
* Gets the messaging extensions handler.
*/
public get messageExtensions (): MessageExtensions<TState> {
return this._messageExtensions
}
/**
* Gets the meetings handler.
*/
public get meetings (): Meetings<TState> {
return this._meetings
}
/**
* Gets the Teams OAuth flow manager.
* @throws Error if no authentication options were configured.
*/
public get teamsAuthManager (): Authorization {
if (!this._teamsAuthManager) {
throw new Error(
'The Application.authentication property is unavailable because no authentication options were configured.'
)
}
return this._teamsAuthManager
}
/**
* Adds a route to the application.
* @param selector - The route selector.
* @param handler - The route handler.
* @param isInvokeRoute - Whether the route is for invoke activities.
*/
public addRoute (selector: RouteSelector, handler: RouteHandler<TState>, isInvokeRoute = false): this {
if (isInvokeRoute) {
this._invokeRoutes.push({ selector, handler })
} else {
this._routes.push({ selector, handler })
}
return this
}
/**
* Runs the application for the given turn context.
* @param turnContext - The turn context.
*/
public async run (turnContext: TurnContext): Promise<void> {
await this.runInternalTeams(turnContext)
}
private async runInternalTeams (turnContext: TurnContext): Promise<boolean> {
return await this.startLongRunningCall(turnContext, async (context) => {
this.startTypingTimer(context)
try {
if (this._teamsOptions.removeRecipientMention && context.activity.type === ActivityTypes.Message) {
context.activity.text = context.activity.removeRecipientMention()
}
const { storage, turnStateFactory } = this._teamsOptions
const state = turnStateFactory()
await state.load(context, storage)
if (!(await this.callEventHandlers(context, state, this._beforeTurn))) {
await state.save(context, storage)
return false
}
if (Array.isArray(this._teamsOptions.fileDownloaders) && this._teamsOptions.fileDownloaders.length > 0) {
const inputFiles = state.temp.inputFiles ?? []
for (let i = 0; i < this._teamsOptions.fileDownloaders.length; i++) {
const files = await this._teamsOptions.fileDownloaders[i].downloadFiles(context, state)
inputFiles.push(...files)
}
state.temp.inputFiles = inputFiles
}
if (context.activity.type === ActivityTypes.Invoke) {
for (let i = 0; i < this._invokeRoutes.length; i++) {
const route = this._invokeRoutes[i]
if (await route.selector(context)) {
await route.handler(context, state)
if (await this.callEventHandlers(context, state, this._afterTurn)) {
await state.save(context, storage)
}
return true
}
}
}
for (let i = 0; i < this._routes.length; i++) {
const route = this._routes[i]
if (await route.selector(context)) {
await route.handler(context, state)
if (await this.callEventHandlers(context, state, this._afterTurn)) {
await state.save(context, storage)
}
return true
}
}
if (await this.callEventHandlers(context, state, this._afterTurn)) {
await state.save(context, storage)
}
return false
} catch (err: any) {
logger.error(err)
throw err
} finally {
this.stopTypingTimer()
}
})
}
/**
* Handles conversation update events.
* @param event - The conversation update event.
* @param handler - The handler for the event.
*/
public onConversationUpdate (
event: TeamsConversationUpdateEvents,
handler: (context: TurnContext, state: TState) => Promise<void>
): this {
if (typeof handler !== 'function') {
throw new Error(
`ConversationUpdate 'handler' for ${event} is ${typeof handler}. Type of 'handler' must be a function.`
)
}
const selector = this.createTeamsConversationUpdateSelector(event)
this.addRoute(selector, handler)
return this
}
/**
* Handles message event updates.
* @param event - The message event.
* @param handler - The handler for the event.
*/
public onMessageEventUpdate (
event: TeamsMessageEvents,
handler: (context: TurnContext, state: TState) => Promise<void>
): this {
if (typeof handler !== 'function') {
throw new Error(
`MessageUpdate 'handler' for ${event} is ${typeof handler}. Type of 'handler' must be a function.`
)
}
const selector = this.createMessageEventUpdateSelector(event)
this.addRoute(selector, handler)
return this
}
/**
* Handles message reactions.
* @param event - The message reaction event.
* @param handler - The handler for the event.
*/
public onMessageReactions (
event: MessageReactionEvents,
handler: (context: TurnContext, state: TState) => Promise<void>
): this {
const selector = this.createMessageReactionSelector(event)
this.addRoute(selector, handler)
return this
}
/**
* Handles file consent accept actions.
* @param handler - The handler for the file consent accept action.
*/
public fileConsentAccept (
handler: (context: TurnContext, state: TState, fileConsentResponse: FileConsentCardResponse) => Promise<void>
): this {
const selector = (context: TurnContext): Promise<boolean> => {
const valueAction = parseValueAction(context.activity.value)
return Promise.resolve(
context.activity.type === ActivityTypes.Invoke &&
context.activity.name === 'fileConsent/invoke' &&
valueAction === 'accept'
)
}
const handlerWrapper = async (context: TurnContext, state: TState) => {
await handler(context, state, context.activity.value as FileConsentCardResponse)
await context.sendActivity({
type: ActivityTypes.InvokeResponse,
value: { status: 200 }
} as Activity)
}
this.addRoute(selector, handlerWrapper, true)
return this
}
/**
* Handles file consent decline actions.
* @param handler - The handler for the file consent decline action.
*/
public fileConsentDecline (
handler: (context: TurnContext, state: TState, fileConsentResponse: FileConsentCardResponse) => Promise<void>
): this {
const selector = (context: TurnContext): Promise<boolean> => {
const valueAction = parseValueAction(context.activity.value)
return Promise.resolve(
context.activity.type === ActivityTypes.Invoke &&
context.activity.name === 'fileConsent/invoke' &&
valueAction === 'decline'
)
}
const handlerWrapper = async (context: TurnContext, state: TState) => {
await handler(context, state, context.activity.value as FileConsentCardResponse)
await context.sendActivity({
type: ActivityTypes.InvokeResponse,
value: { status: 200 }
} as Activity)
}
this.addRoute(selector, handlerWrapper, true)
return this
}
/**
* Handles handoff actions.
* @param handler - The handler for the handoff action.
*/
public onHandoff (handler: (context: TurnContext, state: TState, continuation: string) => Promise<void>): this {
const selector = (context: TurnContext): Promise<boolean> => {
return Promise.resolve(
context.activity.type === ActivityTypes.Invoke && context.activity.name === 'handoff/action'
)
}
const handlerWrapper = async (context: TurnContext, state: TState) => {
const valueContinuation = parseValueContinuation(context.activity.value)
await handler(context, state, valueContinuation)
await context.sendActivity({
type: ActivityTypes.InvokeResponse,
value: { status: 200 }
} as Activity)
}
this.addRoute(selector, handlerWrapper, true)
return this
}
/**
* Gets the channels of a team.
* @param context - The turn context, conversation reference, or activity.
*/
public async getTeamChannels (
context: TurnContext | ConversationReference | Activity
): Promise<ChannelInfo[]> {
let teamsChannels: ChannelInfo[] = []
const reference: ConversationReference = this.getConversationReference(context)
if (reference.conversation?.conversationType === 'channel') {
await this.continueConversationAsync(reference, async (ctx) => {
const teamId =
ctx.activity?.channelData?.team?.id ??
(ctx.activity?.conversation?.name === undefined ? ctx.activity?.conversation?.id : undefined)
if (teamId) {
teamsChannels = await TeamsInfo.getTeamChannels(ctx, teamId)
}
})
}
return teamsChannels
}
/**
* Gets the details of a team.
* @param context - The turn context, conversation reference, or activity.
*/
public async getTeamDetails (
context: TurnContext | ConversationReference | Activity
): Promise<TeamDetails | undefined> {
let teamDetails: TeamDetails | undefined
const reference: ConversationReference = this.getConversationReference(context)
if (reference.conversation?.conversationType === 'channel') {
await this.continueConversationAsync(reference, async (ctx) => {
const teamId =
ctx.activity?.channelData?.team?.id ??
(ctx.activity?.conversation?.name === undefined ? ctx.activity?.conversation?.id : undefined)
if (teamId) {
teamDetails = await TeamsInfo.getTeamDetails(ctx, teamId)
}
})
}
return teamDetails
}
/**
* Gets the paged members of a team.
* @param context - The turn context or conversation reference.
* @param pageSize - The number of members per page.
* @param continuationToken - The continuation token for pagination.
*/
public async getPagedMembers (
context: TurnContext | ConversationReference,
pageSize?: number,
continuationToken?: string
): Promise<TeamsPagedMembersResult> {
let pagedMembers: TeamsPagedMembersResult = { members: [], continuationToken: '' }
await this.continueConversationAsync(context, async (ctx) => {
pagedMembers = await TeamsInfo.getPagedMembers(ctx, pageSize, continuationToken)
})
return pagedMembers
}
/**
* Handles Teams read receipt events.
* @param handler - The handler for the read receipt event.
*/
public onTeamsReadReceipt (
handler: (context: TurnContext, state: TState, readReceiptInfo: ReadReceiptInfo) => Promise<void>
): this {
const selector = (context: TurnContext): Promise<boolean> => {
return Promise.resolve(
context.activity.type === ActivityTypes.Event &&
context.activity.channelId === 'msteams' &&
context.activity.name === 'application/vnd.microsoft/readReceipt'
)
}
const handlerWrapper = (context: TurnContext, state: TState): Promise<void> => {
const readReceiptInfo = context.activity.value as ReadReceiptInfo
return handler(context, state, readReceiptInfo)
}
this.addRoute(selector, handlerWrapper)
return this
}
private createMessageEventUpdateSelector (event: TeamsMessageEvents): RouteSelector {
switch (event) {
case 'editMessage':
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.MessageUpdate &&
context?.activity?.channelData?.eventType === event
)
}
case 'softDeleteMessage':
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.MessageDelete &&
context?.activity?.channelData?.eventType === event
)
}
case 'undeleteMessage':
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.MessageUpdate &&
context?.activity?.channelData?.eventType === event
)
}
default:
throw new Error(`Invalid TeamsMessageEvent type: ${event}`)
}
}
private createMessageReactionSelector (event: MessageReactionEvents): RouteSelector {
switch (event) {
case 'reactionsAdded':
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.MessageReaction &&
Array.isArray(context?.activity?.reactionsAdded) &&
context.activity.reactionsAdded.length > 0
)
}
case 'reactionsRemoved':
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.MessageReaction &&
Array.isArray(context?.activity?.reactionsRemoved) &&
context.activity.reactionsRemoved.length > 0
)
}
}
}
private getConversationReference (
context: TurnContext | Activity | ConversationReference
): ConversationReference {
let reference: ConversationReference
if (typeof (context as TurnContext).activity === 'object') {
reference = (context as TurnContext).activity.getConversationReference()
} else if (typeof (context as Activity).type === 'string') {
reference = (context as Activity).getConversationReference()
} else {
reference = context as ConversationReference
}
return reference
}
private createTeamsConversationUpdateSelector (event: TeamsConversationUpdateEvents): RouteSelector {
switch (event) {
case 'channelCreated':
case 'channelDeleted':
case 'channelRenamed':
case 'channelRestored':
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.ConversationUpdate &&
context?.activity?.channelData?.eventType === event &&
context?.activity?.channelData?.channel &&
context.activity.channelData?.team
)
}
case 'membersAdded':
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.ConversationUpdate &&
Array.isArray(context?.activity?.membersAdded) &&
context.activity.membersAdded.length > 0
)
}
case 'membersRemoved':
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.ConversationUpdate &&
Array.isArray(context?.activity?.membersRemoved) &&
context.activity.membersRemoved.length > 0
)
}
case 'teamRenamed':
case 'teamDeleted':
case 'teamHardDeleted':
case 'teamArchived':
case 'teamUnarchived':
case 'teamRestored':
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.ConversationUpdate &&
context?.activity?.channelData?.eventType === event &&
context?.activity?.channelData?.team
)
}
default:
return (context: TurnContext) => {
return Promise.resolve(
context?.activity?.type === ActivityTypes.ConversationUpdate &&
context?.activity?.channelData?.eventType === event
)
}
}
}
}