From 97414b370cad1568a07e0d9a87a3ddac7f4cc488 Mon Sep 17 00:00:00 2001 From: Yiqing Zhao Date: Mon, 12 Aug 2024 16:53:10 +0800 Subject: [PATCH 1/3] helper functions for notification bot --- js/packages/teams-ai/src/Application.ts | 153 ++++++++++++++++++++++-- 1 file changed, 145 insertions(+), 8 deletions(-) diff --git a/js/packages/teams-ai/src/Application.ts b/js/packages/teams-ai/src/Application.ts index 23977335c..90277d772 100644 --- a/js/packages/teams-ai/src/Application.ts +++ b/js/packages/teams-ai/src/Application.ts @@ -10,11 +10,15 @@ import { Activity, ActivityTypes, BotAdapter, + ChannelInfo, ConversationReference, FileConsentCardResponse, O365ConnectorCardActionQuery, ResourceResponse, Storage, + TeamDetails, + TeamsInfo, + TeamsPagedMembersResult, TurnContext } from 'botbuilder'; @@ -514,14 +518,7 @@ export class Application { } // Identify conversation reference - let reference: Partial; - if (typeof (context as TurnContext).activity == 'object') { - reference = TurnContext.getConversationReference((context as TurnContext).activity); - } else if (typeof (context as Partial).type == 'string') { - reference = TurnContext.getConversationReference(context as Partial); - } else { - reference = context as Partial; - } + const reference: Partial = getConversationReference(context); await this.adapter.continueConversationAsync(this._options.botAppId ?? '', reference, logic); } @@ -891,6 +888,105 @@ export class Application { return response; } + /** + * Retrieves the list of team channels for a given context. + * @param context - The context of the conversation, which can be a TurnContext, + * Partial, or Partial. + * @returns A promise that resolves to an array of ChannelInfo objects if the bot is installed into a team, otherwise returns an empty array. + */ + public async getTeamChannels(context: TurnContext): Promise; + public async getTeamChannels(conversationReference: Partial): Promise; + public async getTeamChannels(activity: Partial): Promise; + public async getTeamChannels( + context: TurnContext | Partial | Partial + ): Promise { + let teamsChannels: ChannelInfo[] = []; + + // Identify conversation reference + const reference: Partial = 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; + } + + /** + * Retrieves the team details for a given context. + * @param context - The context of the conversation, which can be a TurnContext, + * Partial, or Partial. + * @returns A promise that resolves to an array of ChannelInfo objects if the bot is installed into a team, otherwise returns an empty array. + */ + public async getTeamDetails(context: TurnContext): Promise; + public async getTeamDetails( + conversationReference: Partial + ): Promise; + public async getTeamDetails(activity: Partial): Promise; + public async getTeamDetails( + context: TurnContext | Partial | Partial + ): Promise { + let teamDetails: TeamDetails | undefined = undefined; + + // Identify conversation reference + const reference: Partial = 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 a pagined list of members of one-on-one, group, or team conversation. + * @param context - The context for the current turn with the user. + * @param {number} pageSize - Suggested number of entries on a page. + * @param {string} continuationToken - A continuation token. + * @returns The TeamsPagedMembersResult with the list of members. + */ + public async getPagedMembers( + context: TurnContext, + pageSize?: number, + continuationToken?: string + ): Promise; + public async getPagedMembers( + reference: Partial, + pageSize?: number, + continuationToken?: string + ): Promise; + public async getPagedMembers( + activity: Partial, + pageSize?: number, + continuationToken?: string + ): Promise; + public async getPagedMembers( + context: TurnContext | Partial | Partial, + pageSize?: number, + continuationToken?: string + ): Promise { + let pagedMembers: TeamsPagedMembersResult = { members: [], continuationToken: '' }; + await this.continueConversationAsync(context, async (ctx) => { + pagedMembers = await TeamsInfo.getPagedMembers(ctx, pageSize, continuationToken); + }); + + return pagedMembers; + } + /** * Manually start a timer to periodically send "typing" activities. * @remarks @@ -1359,6 +1455,47 @@ function createSignInSelector(startSignIn?: boolean | Selector): Selector { }; } +/** + * Retrieves a conversation reference from the given TurnContext. + * @param {TurnContext} context - The context to extract the conversation reference from. + * @returns {Partial} The extracted conversation reference. + */ +function getConversationReference(context: TurnContext): Partial; +/** + * Retrieves a conversation reference from the given activity. + * @param {Partial} activity - The activity to extract the conversation reference from. + * @returns {Partial} The extracted conversation reference. + */ +function getConversationReference(activity: Partial): Partial; +/** + * Retrieves a conversation reference from the given reference. + * @param {Partial} reference - The reference to extract the conversation reference from. + * @returns {Partial} The extracted conversation reference. + */ +function getConversationReference(reference: Partial): Partial; +/** + * Retrieves a conversation reference from the given context, activity, or reference. + * Overloaded function signatures: + * - getConversationReference(context: TurnContext): Partial + * - getConversationReference(activity: Partial): Partial + * - getConversationReference(reference: Partial): Partial + * @param {TurnContext | Partial | Partial} context - The context, activity, or reference to extract the conversation reference from. + * @returns {Partial} The extracted conversation reference. + */ +function getConversationReference( + context: TurnContext | Partial | Partial +): Partial { + let reference: Partial; + if (typeof (context as TurnContext).activity == 'object') { + reference = TurnContext.getConversationReference((context as TurnContext).activity); + } else if (typeof (context as Partial).type == 'string') { + reference = TurnContext.getConversationReference(context as Partial); + } else { + reference = context as Partial; + } + return reference; +} + /** * @private */ From e962a0246c54a81a79996856336eb90c223f1c18 Mon Sep 17 00:00:00 2001 From: Yiqing Zhao Date: Wed, 14 Aug 2024 14:40:51 +0800 Subject: [PATCH 2/3] feat: update comments --- js/packages/teams-ai/src/Application.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/packages/teams-ai/src/Application.ts b/js/packages/teams-ai/src/Application.ts index 90277d772..6b756af22 100644 --- a/js/packages/teams-ai/src/Application.ts +++ b/js/packages/teams-ai/src/Application.ts @@ -955,7 +955,7 @@ export class Application { /** * Gets a pagined list of members of one-on-one, group, or team conversation. * @param context - The context for the current turn with the user. - * @param {number} pageSize - Suggested number of entries on a page. + * @param {number} pageSize - Suggested number of entries on a page. Page size less than 50, are treated as 50, and greater than 500, are capped at 500. * @param {string} continuationToken - A continuation token. * @returns The TeamsPagedMembersResult with the list of members. */ @@ -981,6 +981,7 @@ export class Application { ): Promise { let pagedMembers: TeamsPagedMembersResult = { members: [], continuationToken: '' }; await this.continueConversationAsync(context, async (ctx) => { + // Page size less than 50, are treated as 50, and greater than 500, are capped at 500. pagedMembers = await TeamsInfo.getPagedMembers(ctx, pageSize, continuationToken); }); From 0bd33054ca64f72a4cdbb7ef5a3670480f006181 Mon Sep 17 00:00:00 2001 From: Yiqing Zhao Date: Sun, 8 Sep 2024 19:01:22 +0800 Subject: [PATCH 3/3] test: add unit tests for new API --- js/packages/teams-ai/src/Application.spec.ts | 308 ++++++++++++++++++- js/packages/teams-ai/src/Application.ts | 4 +- 2 files changed, 309 insertions(+), 3 deletions(-) diff --git a/js/packages/teams-ai/src/Application.spec.ts b/js/packages/teams-ai/src/Application.spec.ts index 273fb5be5..0d9506fe0 100644 --- a/js/packages/teams-ai/src/Application.spec.ts +++ b/js/packages/teams-ai/src/Application.spec.ts @@ -11,7 +11,14 @@ import { MessageReactionTypes, TestAdapter, O365ConnectorCardActionQuery, - FileConsentCardResponse + FileConsentCardResponse, + TurnContext, + TeamsInfo, + ConversationReference, + TeamDetails, + ChannelInfo, + TeamsChannelAccount, + TeamsPagedMembersResult } from 'botbuilder'; import { @@ -920,4 +927,303 @@ describe('Application', () => { }); }); }); + + describe('getTeamChannels', () => { + let app = new Application(); + let stubContext: sinon.SinonStubbedInstance; + const returnedChannels: ChannelInfo[] = [{ id: 'testChannelId', name: 'testName' }]; + + beforeEach(() => { + app = new Application({ adapter: new TeamsAdapter() }); + stubContext = sandbox.createStubInstance(TurnContext); + const stubAdapter = sandbox.createStubInstance(CloudAdapter); + ( + stubAdapter.continueConversationAsync as unknown as sinon.SinonStub< + [string, Partial, (context: TurnContext) => Promise], + Promise + > + ).callsFake(async (fakeBotAppId, ref, logic) => { + await logic(stubContext); + }); + sandbox.stub(app, 'adapter').get(() => stubAdapter); + sandbox.stub(TeamsInfo, 'getTeamChannels').resolves(returnedChannels); + }); + + it('should return empty array if conversationType is not channel', async () => { + sandbox.stub(TurnContext, 'getConversationReference').returns({ + conversation: { + isGroup: false, + conversationType: 'personal', + id: 'testChannelId', + name: 'testName' + } + }); + const continueConversationAsyncStub = sandbox.stub(testAdapter, 'continueConversationAsync').resolves(); + + const channels = await app.getTeamChannels(new TurnContext(testAdapter, {})); + + assert.equal(channels.length, 0); + assert(continueConversationAsyncStub.notCalled); + }); + + it('should return channel array if conversationType is channel with defined teamId', async () => { + sandbox.stub(TurnContext, 'getConversationReference').returns({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + sandbox.stub(TurnContext.prototype, 'activity').get(() => { + return { + channelData: { + team: { + id: 'testId' + } + } + }; + }); + + const channels = await app.getTeamChannels({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + + assert.deepEqual(channels, returnedChannels); + }); + + it('should return channel array if conversationType is channel with defined conversationId and undefined name', async () => { + sandbox.stub(TurnContext, 'getConversationReference').returns({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + sandbox.stub(TurnContext.prototype, 'activity').get(() => { + return { + conversation: { + id: 'teamId' + } + }; + }); + + const channels = await app.getTeamChannels({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + + assert.deepEqual(channels, returnedChannels); + }); + + it('should return empty array if conversationType is channel with defined name', async () => { + sandbox.stub(TurnContext, 'getConversationReference').returns({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + sandbox.stub(TurnContext.prototype, 'activity').get(() => { + return { + conversation: { + name: 'teamName', + id: 'teamId' + } + }; + }); + + const channels = await app.getTeamChannels({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + + assert.equal(channels.length, 0); + }); + }); + + describe('getTeamDetails', () => { + let app = new Application(); + let stubContext: sinon.SinonStubbedInstance; + const returnedDetails: TeamDetails = { + id: 'teamId', + name: 'teamName' + }; + + beforeEach(() => { + app = new Application({ adapter: new TeamsAdapter() }); + stubContext = sandbox.createStubInstance(TurnContext); + const stubAdapter = sandbox.createStubInstance(CloudAdapter); + ( + stubAdapter.continueConversationAsync as unknown as sinon.SinonStub< + [string, Partial, (context: TurnContext) => Promise], + Promise + > + ).callsFake(async (fakeBotAppId, ref, logic) => { + await logic(stubContext); + }); + sandbox.stub(app, 'adapter').get(() => stubAdapter); + sandbox.stub(TeamsInfo, 'getTeamDetails').resolves(returnedDetails); + }); + + it('should return undefined details if conversationType is not channel', async () => { + sandbox.stub(TurnContext, 'getConversationReference').returns({ + conversation: { + isGroup: false, + conversationType: 'personal', + id: 'testChannelId', + name: 'testName' + } + }); + const continueConversationAsyncStub = sandbox.stub(testAdapter, 'continueConversationAsync').resolves(); + + const details = await app.getTeamDetails(new TurnContext(testAdapter, {})); + + assert.equal(details, undefined); + assert(continueConversationAsyncStub.notCalled); + }); + + it('should return team details if conversationType is channel with defined teamId', async () => { + sandbox.stub(TurnContext, 'getConversationReference').returns({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + sandbox.stub(TurnContext.prototype, 'activity').get(() => { + return { + channelData: { + team: { + id: 'testId' + } + } + }; + }); + + const details = await app.getTeamDetails({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + + assert.deepEqual(details, returnedDetails); + }); + + it('should return team details if conversationType is channel with defined conversationId and undefined name', async () => { + sandbox.stub(TurnContext, 'getConversationReference').returns({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + sandbox.stub(TurnContext.prototype, 'activity').get(() => { + return { + conversation: { + id: 'teamId' + } + }; + }); + + const details = await app.getTeamDetails({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + + assert.deepEqual(details, returnedDetails); + }); + + it('should return undefined if conversationType is channel with defined name', async () => { + sandbox.stub(TurnContext, 'getConversationReference').returns({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + sandbox.stub(TurnContext.prototype, 'activity').get(() => { + return { + conversation: { + name: 'teamName', + id: 'teamId' + } + }; + }); + + const details = await app.getTeamDetails({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + + assert.equal(details, undefined); + }); + }); + + describe('getPagedMembers', () => { + let app = new Application(); + let stubContext: sinon.SinonStubbedInstance; + const returnedPagedMembers: TeamsPagedMembersResult = { + continuationToken: 'token', + members: [{} as TeamsChannelAccount, {} as TeamsChannelAccount] + }; + + beforeEach(() => { + app = new Application({ adapter: new TeamsAdapter() }); + stubContext = sandbox.createStubInstance(TurnContext); + const stubAdapter = sandbox.createStubInstance(CloudAdapter); + ( + stubAdapter.continueConversationAsync as unknown as sinon.SinonStub< + [string, Partial, (context: TurnContext) => Promise], + Promise + > + ).callsFake(async (fakeBotAppId, ref, logic) => { + await logic(stubContext); + }); + sandbox.stub(app, 'adapter').get(() => stubAdapter); + sandbox.stub(TeamsInfo, 'getPagedMembers').resolves(returnedPagedMembers); + }); + + it('should return paged members result', async () => { + const pagedMembers = await app.getPagedMembers({ + conversation: { + isGroup: false, + conversationType: 'channel', + id: 'testChannelId', + name: 'testName' + } + }); + + assert.deepEqual(pagedMembers, returnedPagedMembers); + }); + }); }); diff --git a/js/packages/teams-ai/src/Application.ts b/js/packages/teams-ai/src/Application.ts index 6b756af22..8421445af 100644 --- a/js/packages/teams-ai/src/Application.ts +++ b/js/packages/teams-ai/src/Application.ts @@ -909,7 +909,7 @@ export class Application { await this.continueConversationAsync(reference, async (ctx) => { const teamId = ctx.activity?.channelData?.team?.id ?? - (ctx.activity.conversation.name === undefined ? ctx.activity?.conversation?.id : undefined); + (ctx.activity?.conversation?.name === undefined ? ctx.activity?.conversation?.id : undefined); if (teamId) { teamsChannels = await TeamsInfo.getTeamChannels(ctx, teamId); } @@ -942,7 +942,7 @@ export class Application { await this.continueConversationAsync(reference, async (ctx) => { const teamId = ctx.activity?.channelData?.team?.id ?? - (ctx.activity.conversation.name === undefined ? ctx.activity?.conversation?.id : undefined); + (ctx.activity?.conversation?.name === undefined ? ctx.activity?.conversation?.id : undefined); if (teamId) { teamDetails = await TeamsInfo.getTeamDetails(ctx, teamId); }