Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: send targeted meeting notification in Teams meetings #4385

Merged
merged 8 commits into from
Dec 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions libraries/botbuilder/etc/botbuilder.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import { TeamDetails } from 'botbuilder-core';
import { TeamInfo } from 'botbuilder-core';
import { TeamsChannelAccount } from 'botbuilder-core';
import { TeamsMeetingInfo } from 'botbuilder-core';
import { TeamsMeetingNotification } from 'botbuilder-core';
import { TeamsMeetingNotificationRecipientFailureInfos } from 'botbuilder-core';
import { TeamsMeetingParticipant } from 'botbuilder-core';
import { TeamsPagedMembersResult } from 'botbuilder-core';
import { TenantInfo } from 'botbuilder-core';
Expand Down Expand Up @@ -448,6 +450,7 @@ export class TeamsInfo {
static getTeamMember(context: TurnContext, teamId?: string, userId?: string): Promise<TeamsChannelAccount>;
// @deprecated
static getTeamMembers(context: TurnContext, teamId?: string): Promise<TeamsChannelAccount[]>;
static sendMeetingNotification(context: TurnContext, notification: TeamsMeetingNotification, meetingId?: string): Promise<TeamsMeetingNotificationRecipientFailureInfos | {}>;
static sendMessageToTeamsChannel(context: TurnContext, activity: Activity, teamsChannelId: string, botAppId?: string): Promise<[ConversationReference, string]>;
}

Expand Down
31 changes: 31 additions & 0 deletions libraries/botbuilder/src/teamsInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
TeamsMeetingParticipant,
TeamsMeetingInfo,
Channels,
TeamsMeetingNotification,
TeamsMeetingNotificationRecipientFailureInfos,
} from 'botbuilder-core';
import { ConnectorClient, TeamsConnectorClient, TeamsConnectorModels } from 'botframework-connector';

Expand Down Expand Up @@ -333,6 +335,35 @@ export class TeamsInfo {
return await this.getMemberInternal(this.getConnectorClient(context), t, userId);
}

/**
* Sends a meeting notification to specific users in a Teams meeting.
*
* @param context The [TurnContext](xref:botbuilder-core.TurnContext) for this turn.
* @param notification The meeting notification payload.
* @param meetingId Id of the Teams meeting.
* @returns Promise with either an empty object if notifications were successfully sent to all recipients or
* [TeamsMeetingNotificationRecipientFailureInfos](xref:botframework-schema.TeamsMeetingNotificationRecipientFailureInfos) if notifications
* were sent to some but not all recipients.
*/
static async sendMeetingNotification(
context: TurnContext,
notification: TeamsMeetingNotification,
singhk97 marked this conversation as resolved.
Show resolved Hide resolved
meetingId?: string
): Promise<TeamsMeetingNotificationRecipientFailureInfos | {}> {
const activity = context.activity;

if (meetingId == null) {
const meeting = teamsGetTeamMeetingInfo(activity);
meetingId = meeting?.id;
}

if (!meetingId) {
throw new Error('meetingId is required.');
}

return await this.getTeamsConnectorClient(context).teams.sendMeetingNotification(meetingId, notification);
}

/**
* @private
*/
Expand Down
193 changes: 193 additions & 0 deletions libraries/botbuilder/tests/teamsInfo.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,199 @@ describe('TeamsInfo', function () {
});
});

describe('sendTeamsMeetingNotification()', function () {
it('should correctly map notification object as the request body of the POST request', async function () {
const notification = {
type: 'targetedMeetingNotification',
value: {
recipients: ['random recipient id'],
surfaces: [
{
surface: 'meetingStage',
contentType: 'task',
content: {
value: {
height: '3',
width: '4',
title: "this is Johnny's test",
url: 'https://www.bing.com',
},
},
},
],
},
channelData: {
onBehalfOf: [
// support for user attributions
{
itemid: 0,
mentionType: 'person',
mri: 'random admin',
displayName: 'admin',
},
],
},
};
singhk97 marked this conversation as resolved.
Show resolved Hide resolved
const meetingId = 'randomGUID';
const { expectedAuthHeader, expectation: fetchOauthToken } = nockOauth();

const sendTeamsMeetingNotificationExpectation = nock('https://smba.trafficmanager.net/amer')
.post(`/v1/meetings/${meetingId}/notification`, notification)
.matchHeader('Authorization', expectedAuthHeader)
.reply(202, {});

const context = new TestContext(teamActivity);
context.turnState.set(context.adapter.ConnectorClientKey, connectorClient);
// if notification object wasn't passed as request body, test would fail
await TeamsInfo.sendMeetingNotification(context, notification, meetingId);

assert(fetchOauthToken.isDone());
assert(sendTeamsMeetingNotificationExpectation.isDone());
});

it('should return an empty object if a 202 status code was returned', async function () {
const notification = {};
const meetingId = 'randomGUID';
const { expectedAuthHeader, expectation: fetchOauthToken } = nockOauth();

const sendTeamsMeetingNotificationExpectation = nock('https://smba.trafficmanager.net/amer')
.post(`/v1/meetings/${meetingId}/notification`, notification)
.matchHeader('Authorization', expectedAuthHeader)
.reply(202, {});

const context = new TestContext(teamActivity);
context.turnState.set(context.adapter.ConnectorClientKey, connectorClient);
const sendTeamsMeetingNotification = await TeamsInfo.sendMeetingNotification(
context,
notification,
meetingId
);

assert(fetchOauthToken.isDone());
assert(sendTeamsMeetingNotificationExpectation.isDone());

const isEmptyObject = (obj) => Object.keys(obj).length == 0;
assert(isEmptyObject(sendTeamsMeetingNotification));
});

it('should return a TeamsMeetingNotificationRecipientFailureInfos if a 207 status code was returned', async function () {
const notification = {};
const meetingId = 'randomGUID';
const { expectedAuthHeader, expectation: fetchOauthToken } = nockOauth();

const recipientsFailureInfo = {
recipientsFailureInfo: [
{
recipientMri: '8:orgid:4e8a10c0-4687-4f0a-9ed6-95f28d67c102',
failureReason: 'Invalid recipient. Recipient not in roster',
errorCode: 'MemberNotFoundInConversation',
},
{
recipientMri: '8:orgid:4e8a10c0-4687-4f0a-9ed6-95f28d67c103',
failureReason: 'Invalid recipient. Recipient not in roster',
errorCode: 'MemberNotFoundInConversation',
},
],
};

const sendTeamsMeetingNotificationExpectation = nock('https://smba.trafficmanager.net/amer')
.post(`/v1/meetings/${meetingId}/notification`, notification)
.matchHeader('Authorization', expectedAuthHeader)
.reply(207, recipientsFailureInfo);

const context = new TestContext(teamActivity);
context.turnState.set(context.adapter.ConnectorClientKey, connectorClient);
const sendTeamsMeetingNotification = await TeamsInfo.sendMeetingNotification(
context,
notification,
meetingId
);

assert(fetchOauthToken.isDone());
assert(sendTeamsMeetingNotificationExpectation.isDone());

assert.deepEqual(sendTeamsMeetingNotification, recipientsFailureInfo);
});

it('should return standard error response if a 4xx status code was returned', async function () {
const notification = {};
const meetingId = 'randomGUID';
const { expectedAuthHeader, expectation: fetchOauthToken } = nockOauth();

const errorResponse = { error: { code: 'BadSyntax', message: 'Payload is incorrect' } };

const sendTeamsMeetingNotificationExpectation = nock('https://smba.trafficmanager.net/amer')
.post(`/v1/meetings/${meetingId}/notification`, notification)
.matchHeader('Authorization', expectedAuthHeader)
.reply(400, errorResponse);

const context = new TestContext(teamActivity);
context.turnState.set(context.adapter.ConnectorClientKey, connectorClient);

let isErrorThrown = false;
try {
await TeamsInfo.sendMeetingNotification(context, notification, meetingId);
} catch (e) {
assert.deepEqual(errorResponse, e.body);
isErrorThrown = true;
}

assert(isErrorThrown);

assert(fetchOauthToken.isDone());
assert(sendTeamsMeetingNotificationExpectation.isDone());
});

it('should throw an error if an empty meeting id is provided', async function () {
const notification = {};
const emptyMeetingId = '';
const { expectedAuthHeader, expectation: fetchOauthToken } = nockOauth();

const sendTeamsMeetingNotificationExpectation = nock('https://smba.trafficmanager.net/amer')
.post(`/v1/meetings/${emptyMeetingId}/notification`, notification)
.matchHeader('Authorization', expectedAuthHeader)
.reply(202, {});

const context = new TestContext(teamActivity);
context.turnState.set(context.adapter.ConnectorClientKey, connectorClient);

let isErrorThrown = false;
try {
await TeamsInfo.sendMeetingNotification(context, notification, emptyMeetingId);
} catch (e) {
assert(typeof e, 'Error');
assert(e.message, 'meetingId is required.');
isErrorThrown = true;
}

assert(isErrorThrown);
assert(fetchOauthToken.isDone() === false);
assert(sendTeamsMeetingNotificationExpectation.isDone() === false);
});

it('should get the meeting id from the context object if no meeting id is provided', async function () {
const notification = {};
const { expectedAuthHeader, expectation: fetchOauthToken } = nockOauth();

const context = new TestContext(teamActivity);

const sendTeamsMeetingNotificationExpectation = nock('https://smba.trafficmanager.net/amer')
.post(
`/v1/meetings/${encodeURIComponent(teamActivity.channelData.meeting.id)}/notification`,
notification
)
.matchHeader('Authorization', expectedAuthHeader)
.reply(202, {});

context.turnState.set(context.adapter.ConnectorClientKey, connectorClient);

await TeamsInfo.sendMeetingNotification(context, notification);

assert(fetchOauthToken.isDone());
assert(sendTeamsMeetingNotificationExpectation.isDone());
});
});

describe('private methods', function () {
describe('getConnectorClient()', function () {
it("should error if the context doesn't have an adapter", function () {
Expand Down
27 changes: 26 additions & 1 deletion libraries/botframework-connector/src/teams/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
*/

import { HttpResponse, ServiceClientOptions, RequestOptionsBase } from '@azure/ms-rest-js';
import { ConversationList, TeamDetails, TeamsMeetingInfo, TeamsMeetingParticipant } from 'botframework-schema';
import {
ConversationList,
TeamDetails,
TeamsMeetingInfo,
TeamsMeetingNotificationRecipientFailureInfos,
TeamsMeetingParticipant,
} from 'botframework-schema';

/**
* @interface
Expand Down Expand Up @@ -118,3 +124,22 @@ export type TeamsMeetingInfoResponse = TeamsMeetingInfo & {
parsedBody: TeamsMeetingParticipant;
};
};

/**
* Contains response data for the sendMeetingNotification operation.
*/
export type TeamsSendMeetingNotificationResponse = TeamsMeetingNotificationRecipientFailureInfos & {
/**
* The underlying HTTP response.
*/
_response: HttpResponse & {
/**
* The response body as text (string format)
*/
bodyAsText: string;
/**
* The response body as parsed JSON or XML
*/
parsedBody: TeamsMeetingNotificationRecipientFailureInfos | {};
};
};
Loading