diff --git a/libraries/botbuilder/etc/botbuilder.api.md b/libraries/botbuilder/etc/botbuilder.api.md index 031228616a..048de4538f 100644 --- a/libraries/botbuilder/etc/botbuilder.api.md +++ b/libraries/botbuilder/etc/botbuilder.api.md @@ -51,6 +51,7 @@ import { NodeWebSocketFactoryBase } from 'botframework-streaming'; import { O365ConnectorCardActionQuery } from 'botbuilder-core'; import { PagedMembersResult } from 'botbuilder-core'; import { PagedResult } from 'botbuilder-core'; +import { ReadReceiptInfo } from 'botframework-connector'; import { RequestHandler } from 'botframework-streaming'; import { ResourceResponse } from 'botbuilder-core'; import { SigninStateVerificationQuery } from 'botbuilder-core'; @@ -395,6 +396,8 @@ export class TeamsActivityHandler extends ActivityHandler { onTeamsMembersAddedEvent(handler: (membersAdded: TeamsChannelAccount[], teamInfo: TeamInfo, context: TurnContext, next: () => Promise) => Promise): this; protected onTeamsMembersRemoved(context: TurnContext): Promise; onTeamsMembersRemovedEvent(handler: (membersRemoved: TeamsChannelAccount[], teamInfo: TeamInfo, context: TurnContext, next: () => Promise) => Promise): this; + protected onTeamsReadReceipt(context: TurnContext): Promise; + onTeamsReadReceiptEvent(handler: (receiptInfo: ReadReceiptInfo, context: TurnContext, next: () => Promise) => Promise): this; protected onTeamsTeamArchived(context: any): Promise; onTeamsTeamArchivedEvent(handler: (teamInfo: TeamInfo, context: TurnContext, next: () => Promise) => Promise): this; protected onTeamsTeamDeleted(context: any): Promise; diff --git a/libraries/botbuilder/src/teamsActivityHandler.ts b/libraries/botbuilder/src/teamsActivityHandler.ts index f8b97b33ae..25e96cc02a 100644 --- a/libraries/botbuilder/src/teamsActivityHandler.ts +++ b/libraries/botbuilder/src/teamsActivityHandler.ts @@ -33,6 +33,7 @@ import { tokenExchangeOperationName, verifyStateOperationName, } from 'botbuilder-core'; +import { ReadReceiptInfo } from 'botframework-connector'; import { TeamsInfo } from './teamsInfo'; import * as z from 'zod'; @@ -1001,6 +1002,8 @@ export class TeamsActivityHandler extends ActivityHandler { protected async dispatchEventActivity(context: TurnContext): Promise { if (context.activity.channelId === Channels.Msteams) { switch (context.activity.name) { + case 'application/vnd.microsoft.readReceipt': + return this.onTeamsReadReceipt(context); case 'application/vnd.microsoft.meetingStart': return this.onTeamsMeetingStart(context); case 'application/vnd.microsoft.meetingEnd': @@ -1033,6 +1036,17 @@ export class TeamsActivityHandler extends ActivityHandler { await this.handle(context, 'TeamsMeetingEnd', this.defaultNextEvent(context)); } + /** + * Invoked when a read receipt for a previously sent message is received from the connector. + * Override this in a derived class to provide logic for when the bot receives a read receipt event. + * + * @param context The context for this turn. + * @returns A promise that represents the work queued. + */ + protected async onTeamsReadReceipt(context: TurnContext): Promise { + await this.handle(context, 'TeamsReadReceipt', this.defaultNextEvent(context)); + } + /** * Registers a handler for when a Teams meeting starts. * @@ -1082,4 +1096,19 @@ export class TeamsActivityHandler extends ActivityHandler { ); }); } + + /** + * Registers a handler for when a Read Receipt is sent. + * + * @param handler A callback that handles Read Receipt events. + * @returns A promise that represents the work queued. + */ + onTeamsReadReceiptEvent( + handler: (receiptInfo: ReadReceiptInfo, context: TurnContext, next: () => Promise) => Promise + ): this { + return this.on('TeamsReadReceipt', async (context, next) => { + const receiptInfo = context.activity.value; + await handler(new ReadReceiptInfo(receiptInfo.lastReadMessageId), context, next); + }); + } } diff --git a/libraries/botbuilder/tests/teamsActivityHandler.test.js b/libraries/botbuilder/tests/teamsActivityHandler.test.js index 428b745abd..71b381ded7 100644 --- a/libraries/botbuilder/tests/teamsActivityHandler.test.js +++ b/libraries/botbuilder/tests/teamsActivityHandler.test.js @@ -2350,5 +2350,52 @@ describe('TeamsActivityHandler', function () { }) .startTest(); }); + + it('onTeamsReadReceipt routed activity', async function () { + let onTeamsReadReceiptCalled = false; + const bot = new TeamsActivityHandler(); + const activity = { + channelId: Channels.Msteams, + type: 'event', + name: 'application/vnd.microsoft.readReceipt', + value: JSON.parse('{ "lastReadMessageId": 10101010}'), + }; + + bot.onEvent(async (context, next) => { + assert(context, 'context not found'); + assert(next, 'next not found'); + onEventCalled = true; + await next(); + }); + + bot.onTeamsReadReceiptEvent(async (receiptInfo, context, next) => { + assert(receiptInfo, 'receiptInfo not found'); + assert(context, 'context not found'); + assert(next, 'next not found'); + assert.strictEqual(receiptInfo.lastReadMessageId, activity.value.lastReadMessageId); + onTeamsReadReceiptCalled = true; + await next(); + }); + + bot.onDialog(async (context, next) => { + assert(context, 'context not found'); + assert(next, 'next not found'); + onDialogCalled = true; + await next(); + }); + + const adapter = new TestAdapter(async (context) => { + await bot.run(context); + }); + + await adapter + .send(activity) + .then(() => { + assert(onTeamsReadReceiptCalled); + assert(onEventCalled, 'onConversationUpdate handler not called'); + assert(onDialogCalled, 'onDialog handler not called'); + }) + .startTest(); + }); }); }); diff --git a/libraries/botframework-connector/src/teams/index.ts b/libraries/botframework-connector/src/teams/index.ts index 709ceecddf..84f528962e 100644 --- a/libraries/botframework-connector/src/teams/index.ts +++ b/libraries/botframework-connector/src/teams/index.ts @@ -24,3 +24,4 @@ export * from './teamsConnectorClient'; export * from './teamsConnectorClientContext'; export * from './models'; +export * from './readReceiptInfo'; diff --git a/libraries/botframework-connector/src/teams/readReceiptInfo.ts b/libraries/botframework-connector/src/teams/readReceiptInfo.ts new file mode 100644 index 0000000000..792b45dd6f --- /dev/null +++ b/libraries/botframework-connector/src/teams/readReceiptInfo.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * General information about a read receipt. + */ +export class ReadReceiptInfo { + /** + * The id of the last read message. + */ + lastReadMessageId: string; + + /** + * Initializes a new instance of the ReadReceiptInfo class. + * + * @param lastReadMessageId Optional. The id of the last read message. + */ + constructor(lastReadMessageId?: string) { + this.lastReadMessageId = lastReadMessageId; + } + + /** + * Helper method useful for determining if a message has been read. This method + * converts the strings to numbers. If the compareMessageId is less than or equal to + * the lastReadMessageId, then the message has been read. + * + * @param compareMessageId The id of the message to compare. + * @param lastReadMessageId The id of the last message read by the user. + * @returns True if the compareMessageId is less than or equal to the lastReadMessageId. + */ + static isMessageRead(compareMessageId: string, lastReadMessageId: string): boolean { + if ( + compareMessageId && + compareMessageId.trim().length > 0 && + lastReadMessageId && + lastReadMessageId.trim().length > 0 + ) { + const compareMessageIdNum = Number(compareMessageId); + const lastReadMessageIdNum = Number(lastReadMessageId); + + if (compareMessageIdNum && lastReadMessageIdNum) { + return compareMessageIdNum <= lastReadMessageIdNum; + } + } + return false; + } + + /** + * Helper method useful for determining if a message has been read. + * If the compareMessageId is less than or equal to the lastReadMessageId, then the message has been read. + * + * @param compareMessageId The id of the message to compare. + * @returns True if the compareMessageId is less than or equal to the lastReadMessageId. + */ + isMessageRead(compareMessageId: string): boolean { + return ReadReceiptInfo.isMessageRead(compareMessageId, this.lastReadMessageId); + } +} diff --git a/libraries/botframework-connector/tests/teams/readReceiptInfo.test.js b/libraries/botframework-connector/tests/teams/readReceiptInfo.test.js new file mode 100644 index 0000000000..17badb7ebc --- /dev/null +++ b/libraries/botframework-connector/tests/teams/readReceiptInfo.test.js @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const assert = require('assert'); +const { ReadReceiptInfo } = require('../..'); + +describe('ReadReceiptInfo', function () { + const testCases = [ + { title: 'compare msg equal to last', compare: '1000', lastRead: '1000', isRead: true }, + { title: 'compare msg < than last', compare: '1000', lastRead: '1001', isRead: true }, + { title: 'compare msg > than last', compare: '1001', lastRead: '1000', isRead: false }, + { title: 'null compare msg', compare: null, lastRead: '1000', isRead: false }, + { title: 'null last msg', compare: '1000', lastRead: null, isRead: false }, + ]; + + testCases.map((testData) => { + it(testData.title, function () { + const readReceipt = new ReadReceiptInfo(testData.lastRead); + + assert.strictEqual(readReceipt.lastReadMessageId, testData.lastRead); + assert.strictEqual(readReceipt.isMessageRead(testData.compare), testData.isRead); + assert.strictEqual(ReadReceiptInfo.isMessageRead(testData.compare, testData.lastRead), testData.isRead); + }); + }); +});