diff --git a/libraries/botbuilder-core/src/activityHandler.ts b/libraries/botbuilder-core/src/activityHandler.ts index 62056763f6..4ad6bb0329 100644 --- a/libraries/botbuilder-core/src/activityHandler.ts +++ b/libraries/botbuilder-core/src/activityHandler.ts @@ -5,7 +5,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ -import { Activity, ActivityTypes, TurnContext } from '.'; +import { ChannelAccount, MessageReaction, TurnContext } from '.'; +import { ActivityHandlerBase } from './activityHandlerBase'; export type BotHandler = (context: TurnContext, next: () => Promise) => Promise; @@ -54,7 +55,7 @@ export type BotHandler = (context: TurnContext, next: () => Promise) => Pr * }); * ``` */ -export class ActivityHandler { +export class ActivityHandler extends ActivityHandlerBase { protected readonly handlers: {[type: string]: BotHandler[]} = {}; /** @@ -209,72 +210,211 @@ export class ActivityHandler { * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter */ public async run(context: TurnContext): Promise { + await super.run(context); + } + + /** + * Overwrite this method to use different logic than the default initial Activity processing logic. + * @remarks + * The default logic is below: + * ```ts + * await this.handle(context, 'Turn', async () => { + * await super.onTurnActivity(context); + * }); + * ``` + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onTurnActivity(context: TurnContext): Promise { + await this.handle(context, 'Turn', async () => { + await super.onTurnActivity(context); + }); + } + + /** + * Runs all `onMesssage()` handlers before calling the `ActivityHandler.defaultNextEvent()`. + * @remarks + * Developers may overwrite this method when having supporting multiple channels to have a + * channel-tailored experience. + * @remarks + * The default logic is below: + * ```ts + * await await this.handle(context, 'Message', this.defaultNextEvent(context)); + * ``` + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onMessageActivity(context: TurnContext): Promise { + await this.handle(context, 'Message', this.defaultNextEvent(context)); + } + + /** + * Runs all `onUnrecognizedActivityType()` handlers before calling `ActivityHandler.dispatchConversationUpdateActivity()`. + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onUnrecognizedActivity(context: TurnContext): Promise { + await this.handle(context, 'UnrecognizedActivityType', this.defaultNextEvent(context)); + } - if (!context) { - throw new Error(`Missing TurnContext parameter`); + /** + * Runs all `onConversationUpdate()` handlers before calling `ActivityHandler.dispatchConversationUpdateActivity()`. + * @remarks + * The default logic is below: + * ```ts + * await this.handle(context, 'ConversationUpdate', async () => { + * await this.dispatchConversationUpdateActivity(context); + * }); + * ``` + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onConversationUpdateActivity(context: TurnContext): Promise { + await this.handle(context, 'ConversationUpdate', async () => { + await this.dispatchConversationUpdateActivity(context); + }); + } + + /** + * Override this method when dispatching off of a `'ConversationUpdate'` event to trigger other sub-events. + * @remarks + * The default logic is below: + * ```ts + * if (context.activity.membersAdded && context.activity.membersAdded.length > 0) { + * await this.handle(context, 'MembersAdded', this.defaultNextEvent(context)); + * } else if (context.activity.membersRemoved && context.activity.membersRemoved.length > 0) { + * await this.handle(context, 'MembersRemoved', this.defaultNextEvent(context)); + * } else { + * await this.defaultNextEvent(context)(); + * } + * ``` + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async dispatchConversationUpdateActivity(context: TurnContext): Promise { + if (context.activity.membersAdded && context.activity.membersAdded.length > 0) { + await this.handle(context, 'MembersAdded', this.defaultNextEvent(context)); + } else if (context.activity.membersRemoved && context.activity.membersRemoved.length > 0) { + await this.handle(context, 'MembersRemoved', this.defaultNextEvent(context)); + } else { + await this.defaultNextEvent(context)(); } + } - if (!context.activity) { - throw new Error(`TurnContext does not include an activity`); + /** + * Runs all `onMessageReaction()` handlers before calling `ActivityHandler.dispatchMessageReactionActivity()`. + * @remarks + * The default logic is below: + * ```ts + * await this.handle(context, 'MessageReaction', async () => { + * await this.dispatchMessageReactionActivity(context); + * }); + * ``` + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onMessageReactionActivity(context: TurnContext): Promise { + await this.handle(context, 'MessageReaction', async () => { + await this.dispatchMessageReactionActivity(context); + }); + } + + /** + * + * @param reactionsAdded The list of reactions added + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onReactionsAddedActivity(reactionsAdded: MessageReaction[], context: TurnContext): Promise { + await this.handle(context, 'ReactionsAdded', this.defaultNextEvent(context)); + } + + /** + * + * @param reactionsRemoved The list of reactions removed + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onReactionsRemovedActivity(reactionsRemoved: MessageReaction[], context: TurnContext): Promise { + await this.handle(context, 'ReactionsRemoved', this.defaultNextEvent(context)); + } + + /** + * Override this method when dispatching off of a `'MessageReaction'` event to trigger other sub-events. + * @remarks + * If there are no reactionsAdded or reactionsRemoved on the incoming activity, it will call `this.defaultNextEvent` + * which emits the `'Dialog'` event by default. + * The default logic is below: + * ```ts + * if (context.activity.reactionsAdded || context.activity.reactionsRemoved) { + * super.onMessageReactionActivity(context); + * } else { + * await this.defaultNextEvent(context)(); + * } + * ``` + * `super.onMessageReactionActivity()` will dispatch to `onReactionsAddedActivity()` + * or `onReactionsRemovedActivity()`. + * + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async dispatchMessageReactionActivity(context: TurnContext): Promise { + if (context.activity.reactionsAdded || context.activity.reactionsRemoved) { + super.onMessageReactionActivity(context); + } else { + await this.defaultNextEvent(context)(); } + } - if (!context.activity.type) { - throw new Error(`Activity is missing it's type`); + /** + * Runs all `onEvent()` handlers before calling `ActivityHandler.dispatchEventActivity()`. + * @remarks + * The default logic is below: + * ```ts + * await this.handle(context, 'Event', async () => { + * await this.dispatchEventActivity(context); + * }); + * ``` + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onEventActivity(context: TurnContext): Promise { + await this.handle(context, 'Event', async () => { + await this.dispatchEventActivity(context); + }); + } + + /** + * Override this method when dispatching off of a `'Event'` event to trigger other sub-events. + * @remarks + * For certain channels (e.g. Web Chat, custom Direct Line clients), developers can emit + * custom `'event'`-type activities from the client. Developers should then overwrite this method + * to support their custom `'event'` activities. + * + * The default logic is below: + * ```ts + * if (context.activity.name === 'tokens/response') { + * await this.handle(context, 'TokenResponseEvent', this.defaultNextEvent(context)); + * } else { + * await this.defaultNextEvent(context)(); + * } + * ``` + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async dispatchEventActivity(context: TurnContext): Promise { + if (context.activity.name === 'tokens/response') { + await this.handle(context, 'TokenResponseEvent', this.defaultNextEvent(context)); + } else { + await this.defaultNextEvent(context)(); } - - // Allow the dialog system to be triggered at the end of the chain + } + + /** + * Returns an async function that emits the `'Dialog'` event when called. + * Overwrite this function to emit a different default event once all relevant + * events are emitted. + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected defaultNextEvent(context: TurnContext): () => Promise { const runDialogs = async (): Promise => { await this.handle(context, 'Dialog', async () => { // noop }); }; - - // List of all Activity Types: - // https://github.com/Microsoft/botbuilder-js/blob/master/libraries/botframework-schema/src/index.ts#L1627 - await this.handle(context, 'Turn', async () => { - switch (context.activity.type) { - case ActivityTypes.Message: - await this.handle(context, 'Message', runDialogs); - break; - case ActivityTypes.ConversationUpdate: - await this.handle(context, 'ConversationUpdate', async () => { - if (context.activity.membersAdded && context.activity.membersAdded.length > 0) { - await this.handle(context, 'MembersAdded', runDialogs); - } else if (context.activity.membersRemoved && context.activity.membersRemoved.length > 0) { - await this.handle(context, 'MembersRemoved', runDialogs); - } else { - await runDialogs(); - } - }); - break; - case ActivityTypes.MessageReaction: - await this.handle(context, 'MessageReaction', async () => { - if (context.activity.reactionsAdded && context.activity.reactionsAdded.length > 0) { - await this.handle(context, 'ReactionsAdded', runDialogs); - } else if (context.activity.reactionsRemoved && context.activity.reactionsRemoved.length > 0) { - await this.handle(context, 'ReactionsRemoved', runDialogs); - } else { - await runDialogs(); - } - }); - break; - case ActivityTypes.Event: - await this.handle(context, 'Event', async () => { - if (context.activity.name === 'tokens/response') { - await this.handle(context, 'TokenResponseEvent', runDialogs); - } else { - await runDialogs(); - } - }); - break; - default: - // handler for unknown or unhandled types - await this.handle(context, 'UnrecognizedActivityType', runDialogs); - break; - } - }); + return runDialogs; } + /** * Used to bind handlers to events by name * @param type string @@ -318,5 +458,4 @@ export class ActivityHandler { return returnValue; } - } diff --git a/libraries/botbuilder-core/src/activityHandlerBase.ts b/libraries/botbuilder-core/src/activityHandlerBase.ts new file mode 100644 index 0000000000..14c8965ae3 --- /dev/null +++ b/libraries/botbuilder-core/src/activityHandlerBase.ts @@ -0,0 +1,197 @@ +/** + * @module botbuilder + */ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ +import { + ActivityTypes, + ChannelAccount, + MessageReaction, + TurnContext } from '.'; + +/** + * Activity handling base class bots. + * + * @remarks + * This provides an inheritble base class for processing incoming events. + * `onTurnActivity()` contains dispatching logic based on the `Activity.type`. + * Developers should implement the `on*Activity()` methods with processing + * logic for each `Activity.type` their bot supports. + */ +export class ActivityHandlerBase { + /** + * Overwrite this method to use different dispatching logic than by Activity type. + * @remarks + * The default logic is below: + * ```ts + * switch (context.activity.type) { + * case ActivityTypes.Message: + * await this.onMessageActivity(context); + * break; + * case ActivityTypes.ConversationUpdate: + * await this.onConversationUpdateActivity(context); + * break; + * case ActivityTypes.MessageReaction: + * await this.onMessageReactionActivity(context); + * break; + * case ActivityTypes.Event: + * await this.onEventActivity(context); + * break; + * default: + * // handler for unknown or unhandled types + * await this.onUnrecognizedActivity(context); + * break; + * } + * ``` + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onTurnActivity(context: TurnContext): Promise { + switch (context.activity.type) { + case ActivityTypes.Message: + await this.onMessageActivity(context); + break; + case ActivityTypes.ConversationUpdate: + await this.onConversationUpdateActivity(context); + break; + case ActivityTypes.MessageReaction: + await this.onMessageReactionActivity(context); + break; + case ActivityTypes.Event: + await this.onEventActivity(context); + break; + default: + // handler for unknown or unhandled types + await this.onUnrecognizedActivity(context); + break; + } + } + + /** + * Used to process incoming "Message" Activities. Implement this method to process Message activities. + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onMessageActivity(context: TurnContext): Promise { + return; + } + + /** + * Used to process incoming "ConversationUpdate" Activities. Implement this method to process ConversationUpdate activities. + * ConversationUpdate Activties + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onConversationUpdateActivity(context: TurnContext): Promise { + if (context.activity.membersAdded && context.activity.membersAdded.length > 0) { + if (context.activity.membersAdded.filter(m => context.activity.recipient && context.activity.recipient.id !== m.id).length) { + await this.onMembersAddedActivity(context.activity.membersAdded, context); + } + } else if (context.activity.membersRemoved && context.activity.membersRemoved.length > 0) { + if (context.activity.membersRemoved.filter(m => context.activity.recipient && context.activity.recipient.id !== m.id).length) { + await this.onMembersRemovedActivity(context.activity.membersRemoved, context); + } + } + } + + /** + * Used to process incoming "MessageReaction" Activities. Implement this method to process MessageReaction activities. + * @remarks + * MessageReaction Activities can be further broken into subtypes, e.g. ReactionsAdded, ReactionsRemoved. + * These two example subtypes can be determined by inspecting the incoming Activity for the property `reactionsAdded` + * and `reactionsRemoved`. + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onMessageReactionActivity(context: TurnContext): Promise { + if (context.activity.reactionsAdded && context.activity.reactionsAdded.length > 0) { + await this.onReactionsAddedActivity(context.activity.reactionsAdded, context); + } else if (context.activity.reactionsRemoved && context.activity.reactionsRemoved.length > 0) { + await this.onReactionsRemovedActivity(context.activity.reactionsRemoved, context); + } + } + + /** + * Used to process incoming "Event" Activities. Implement this method to process Event activities. + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onEventActivity(context: TurnContext): Promise { + return; + } + + /** + * Used to process incoming Activities with unrecognized types. Implement this method to process these activities. + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onUnrecognizedActivity(context: TurnContext): Promise { + return; + } + + /** + * + * @param membersAdded ChannelAccount A list of all the members added to the conversation, as described by the ConversationUpdate activity. + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onMembersAddedActivity(membersAdded: ChannelAccount[], context: TurnContext): Promise { + return; + } + + /** + * + * @param membersRemoved ChannelAccount A list of all the members removed from the conversation, as described by the ConversationUpdate activity. + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onMembersRemovedActivity(membersRemoved: ChannelAccount[], context: TurnContext): Promise { + return; + } + + /** + * + * @param reactionsAdded MessageReaction The list of reactions added + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onReactionsAddedActivity(reactionsAdded: MessageReaction[], context: TurnContext): Promise { + return; + } + + /** + * + * @param reactionsRemoved MessageReaction The list of reactions removed + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + protected async onReactionsRemovedActivity(reactionsRemoved: MessageReaction[], context: TurnContext): Promise { + return; + } + + /** + * `run()` is the main "activity handler" function used to ingest activities for processing by Activity Type. + * @remarks + * Sample code: + * ```javascript + * server.post('/api/messages', (req, res) => { + * adapter.processActivity(req, res, async (context) => { + * // Route to main dialog. + * await bot.run(context); + * }); + * }); + * ``` + * + * @param context TurnContext A TurnContext representing an incoming Activity from an Adapter + */ + public async run(context: TurnContext): Promise { + + if (!context) { + throw new Error(`Missing TurnContext parameter`); + } + + if (!context.activity) { + throw new Error(`TurnContext does not include an activity`); + } + + if (!context.activity.type) { + throw new Error(`Activity is missing it's type`); + } + + // List of all Activity Types: + // https://github.com/Microsoft/botbuilder-js/blob/master/libraries/botframework-schema/src/index.ts#L1627 + await this.onTurnActivity(context); + } +} diff --git a/libraries/botbuilder-core/src/index.ts b/libraries/botbuilder-core/src/index.ts index b8da29d564..752fe1bad5 100644 --- a/libraries/botbuilder-core/src/index.ts +++ b/libraries/botbuilder-core/src/index.ts @@ -8,6 +8,7 @@ export * from 'botframework-schema'; export * from './activityHandler'; +export * from './activityHandlerBase'; export * from './autoSaveStateMiddleware'; export * from './botAdapter'; export * from './botState'; diff --git a/libraries/botbuilder-core/tests/ActivityHandler.test.js b/libraries/botbuilder-core/tests/ActivityHandler.test.js index baac7f4763..4f6eb4dca3 100644 --- a/libraries/botbuilder-core/tests/ActivityHandler.test.js +++ b/libraries/botbuilder-core/tests/ActivityHandler.test.js @@ -5,9 +5,21 @@ describe('ActivityHandler', function() { const adapter = new TestAdapter(); - async function processActivity(activity, bot) { + async function processActivity(activity, bot, done) { + if (!activity) { + throw new Error('Missing activity'); + } + + if (!bot) { + throw new Error('Missing bot'); + } + + if (!done) { + throw new Error('Missing done'); + } const context = new TurnContext(adapter, activity); - await bot.run(context); + // Adding the catch with `done(error)` makes sure that the correct error is surfaced + await bot.run(context).catch(error => done(error)); } it(`should fire onTurn for any inbound activity`, async function (done) { @@ -20,7 +32,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: 'any'}, bot); + processActivity({type: 'any'}, bot, done); }); it(`should fire onMessage for any message activities`, async function (done) { @@ -33,7 +45,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: 'message'}, bot); + processActivity({type: 'message'}, bot, done); }); it(`calling next allows following events to firing`, async function (done) { @@ -51,7 +63,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: 'message'}, bot); + processActivity({type: 'message'}, bot, done); }); it(`omitting call to next prevents following events from firing`, async function (done) { @@ -68,7 +80,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: 'message'}, bot); + processActivity({type: 'message'}, bot, done); }); it(`binding 2 methods to the same event both fire`, async function (done) { @@ -91,7 +103,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: 'message'}, bot); + processActivity({type: 'message'}, bot, done); }); it(`should fire onConversationUpdate`, async function (done) { @@ -104,7 +116,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: ActivityTypes.ConversationUpdate}, bot); + processActivity({type: ActivityTypes.ConversationUpdate}, bot, done); }); it(`should fire onMembersAdded`, async function (done) { @@ -117,7 +129,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: ActivityTypes.ConversationUpdate, membersAdded: [{id: 1}]}, bot); + processActivity({type: ActivityTypes.ConversationUpdate, membersAdded: [{id: 1}]}, bot, done); }); it(`should fire onMembersRemoved`, async function (done) { @@ -130,7 +142,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: ActivityTypes.ConversationUpdate, membersRemoved: [{id: 1}]}, bot); + processActivity({type: ActivityTypes.ConversationUpdate, membersRemoved: [{id: 1}]}, bot, done); }); it(`should fire onMessageReaction`, async function (done) { @@ -143,7 +155,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: ActivityTypes.MessageReaction}, bot); + processActivity({type: ActivityTypes.MessageReaction}, bot, done); }); it(`should fire onReactionsAdded`, async function (done) { @@ -156,7 +168,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: ActivityTypes.MessageReaction, reactionsAdded: [{type: 'like'}]}, bot); + processActivity({type: ActivityTypes.MessageReaction, reactionsAdded: [{type: 'like'}]}, bot, done); }); it(`should fire onReactionsRemoved`, async function (done) { @@ -169,7 +181,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: ActivityTypes.MessageReaction, reactionsRemoved: [{type: 'like'}]}, bot); + processActivity({type: ActivityTypes.MessageReaction, reactionsRemoved: [{type: 'like'}]}, bot, done); }); it(`should fire onEvent`, async function (done) { @@ -182,7 +194,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: ActivityTypes.Event}, bot); + processActivity({type: ActivityTypes.Event}, bot, done); }); @@ -196,7 +208,7 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: 'foo'}, bot); + processActivity({type: 'foo'}, bot, done); }); it(`should fire onDialog`, async function (done) { @@ -209,7 +221,243 @@ describe('ActivityHandler', function() { await next(); }); - processActivity({type: 'foo'}, bot); + processActivity({type: 'foo'}, bot, done); + }); + + describe('should by default', () => { + let onTurnCalled = false; + let onMessageCalled = false; + let onConversationUpdateCalled = false; + let onMembersAddedCalled = false; + let onMembersRemovedCalled = false; + let onMessageReactionCalled = false; + let onReactionsAddedCalled = false; + let onReactionsRemovedCalled = false; + let onEventCalled = false; + let onTokenResponseEventCalled = false; + let onUnrecognizedActivityTypeCalled = false; + let onDialogCalled = false; + + afterEach(function() { + onTurnCalled = false; + onMessageCalled = false; + onConversationUpdateCalled = false; + onMembersAddedCalled = false; + onMembersRemovedCalled = false; + onMessageReactionCalled = false; + onReactionsAddedCalled = false; + onReactionsRemovedCalled = false; + onEventCalled = false; + onTokenResponseEventCalled = false; + onUnrecognizedActivityTypeCalled = false; + onDialogCalled = false; + }); + + function assertContextAndNext(context, next) { + assert(context, 'context not found'); + assert(next, 'next not found'); + } + + function assertFalseFlag(flag, ...args) { + assert(!flag, `${args[0]}Called should not be true before the ${args.join(', ')} handlers are called.`); + } + + function assertTrueFlag(flag, ...args) { + assert(flag, `${args[0]}Called should be true after the ${args[0]} handlers are called.`); + } + + it('call "onTurn" handlers then dispatch by Activity Type "Message"', (done) => { + const bot = new ActivityHandler(); + bot.onTurn(async (context, next) => { + assertContextAndNext(context, next); + assertFalseFlag(onTurnCalled, 'onTurn'); + onTurnCalled = true; + assertFalseFlag(onConversationUpdateCalled, 'onMessage', 'onTurn'); + await next(); + }); + + bot.onMessage(async (context, next) => { + assertContextAndNext(context, next); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertFalseFlag(onConversationUpdateCalled, 'onMessage', 'onTurn'); + assert(!onMessageCalled, 'onMessage should not be true before onTurn and onMessage handlers complete.'); + onMessageCalled = true; + await next(); + }); + + processActivity({type: ActivityTypes.Message}, bot, done); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertTrueFlag(onMessageCalled, 'onMessage'); + done(); + }); + + it('call "onTurn" handlers then dispatch by Activity Type "ConversationUpdate"', (done) => { + const bot = new ActivityHandler(); + bot.onTurn(async (context, next) => { + assertContextAndNext(context, next); + assertFalseFlag(onTurnCalled, 'onTurn'); + onTurnCalled = true; + assertFalseFlag(onConversationUpdateCalled, 'onConversationUpdate', 'onTurn'); + await next(); + }); + + bot.onConversationUpdate(async (context, next) => { + assertContextAndNext(context, next); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertFalseFlag(onConversationUpdateCalled, 'onConversationUpdate', 'onTurn'); + onConversationUpdateCalled = true; + await next(); + }); + + processActivity({type: ActivityTypes.ConversationUpdate}, bot, done); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertTrueFlag(onConversationUpdateCalled, 'onConversationUpdate'); + done(); + }); + + it('call "onTurn" handlers then dispatch by Activity Type "ConversationUpdate"-subtype "MembersAdded"', (done) => { + const bot = new ActivityHandler(); + bot.onTurn(async (context, next) => { + assertContextAndNext(context, next); + assertFalseFlag(onTurnCalled, 'onTurn'); + onTurnCalled = true; + assertFalseFlag(onMembersAddedCalled, 'onMembersAdded', 'onTurn'); + await next(); + }); + + bot.onMembersAdded(async (context, next) => { + assertContextAndNext(context, next); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertFalseFlag(onMembersAddedCalled, 'onMembersAdded', 'onTurn'); + onMembersAddedCalled = true; + await next(); + }); + + processActivity({type: ActivityTypes.ConversationUpdate, membersAdded: [{id: 1}]}, bot, done); + assertTrueFlag(onTurnCalled, 'onTurn', 'onMembersAdded'); + assertTrueFlag(onMembersAddedCalled, 'onMembersAdded', 'onTurn'); + done(); + }); + + it('call "onTurn" handlers then dispatch by Activity Type "ConversationUpdate"-subtype "MembersRemoved"', (done) => { + const bot = new ActivityHandler(); + bot.onTurn(async (context, next) => { + assertContextAndNext(context, next); + assertFalseFlag(onTurnCalled, 'onTurn'); + onTurnCalled = true; + assertFalseFlag(onMembersRemovedCalled, 'onMembersRemoved', 'onTurn'); + await next(); + }); + + bot.onMembersRemoved(async (context, next) => { + assertContextAndNext(context, next); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertFalseFlag(onMembersRemovedCalled, 'onMembersRemoved', 'onTurn'); + onMembersRemovedCalled = true; + await next(); + }); + + processActivity({type: ActivityTypes.ConversationUpdate, membersRemoved: [{id: 1}]}, bot, done); + assertTrueFlag(onTurnCalled, 'onTurn', 'onMembersRemoved'); + assertTrueFlag(onMembersRemovedCalled, 'onMembersRemoved', 'onTurn'); + done(); + }); + + it('call "onTurn" handlers then dispatch by Activity Type "MessageReaction"', (done) => { + const bot = new ActivityHandler(); + bot.onTurn(async (context, next) => { + assertContextAndNext(context, next); + assertFalseFlag(onTurnCalled, 'onTurn'); + onTurnCalled = true; + assertFalseFlag(onMessageReactionCalled, 'onMessageReaction', 'onTurn'); + await next(); + }); + + bot.onMessageReaction(async (context, next) => { + assertContextAndNext(context, next); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertFalseFlag(onMessageReactionCalled, 'onMessageReaction', 'onTurn'); + onMessageReactionCalled = true; + await next(); + }); + + processActivity({type: ActivityTypes.MessageReaction, reactionsRemoved: [{type: 'like'}]}, bot, done); + assertTrueFlag(onTurnCalled, 'onTurn', 'onMembersAdded'); + assertTrueFlag(onMessageReactionCalled, 'onMessageReaction', 'onTurn'); + done(); + }); + + it('call "onTurn" handlers then dispatch by Activity Type "MessageReaction"-subtype "ReactionsAdded"', (done) => { + const bot = new ActivityHandler(); + bot.onTurn(async (context, next) => { + assertContextAndNext(context, next); + assertFalseFlag(onTurnCalled, 'onTurn'); + onTurnCalled = true; + assertFalseFlag(onMessageReactionCalled, 'onMessageReaction', 'onTurn'); + assertFalseFlag(onReactionsRemovedCalled, 'onReactionsRemoved', 'onMessageReaction', 'onTurn'); + await next(); + }); + + bot.onMessageReaction(async (context, next) => { + assertContextAndNext(context, next); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertFalseFlag(onMessageReactionCalled, 'onMessageReaction', 'onTurn'); + onMessageReactionCalled = true; + assertFalseFlag(onReactionsRemovedCalled, 'onReactionsRemoved', 'onMessageReaction', 'onTurn'); + await next(); + }); + + bot.onReactionsAdded(async (context, next) => { + assertContextAndNext(context, next); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertTrueFlag(onMessageReactionCalled, 'onMessageReaction', 'onTurn'); + assertFalseFlag(onReactionsAddedCalled, 'onReactionsAdded', 'onMessageReaction', 'onTurn'); + onReactionsAddedCalled = true; + await next(); + }); + + processActivity({type: ActivityTypes.MessageReaction, reactionsAdded: [{type: 'like'}]}, bot, done); + assertTrueFlag(onTurnCalled, 'onTurn', 'onMembersAdded'); + assertTrueFlag(onMessageReactionCalled, 'onMessageReaction'); + assertTrueFlag(onReactionsAddedCalled, 'onReactionsAdded', 'onMessageReaction', 'onTurn'); + done(); + }); + + it('call "onTurn" handlers then dispatch by Activity Type "MessageReaction"-subtype "ReactionsRemoved"', (done) => { + const bot = new ActivityHandler(); + bot.onTurn(async (context, next) => { + assertContextAndNext(context, next); + assertFalseFlag(onTurnCalled, 'onTurn'); + onTurnCalled = true; + assertFalseFlag(onMessageReactionCalled, 'onMessageReaction', 'onTurn'); + assertFalseFlag(onReactionsRemovedCalled, 'onReactionsRemoved', 'onMessageReaction', 'onTurn'); + await next(); + }); + + bot.onMessageReaction(async (context, next) => { + assertContextAndNext(context, next); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertFalseFlag(onMessageReactionCalled, 'onMessageReaction', 'onTurn'); + onMessageReactionCalled = true; + assertFalseFlag(onReactionsRemovedCalled, 'onReactionsRemoved', 'onMessageReaction', 'onTurn'); + await next(); + }); + + bot.onReactionsRemoved(async (context, next) => { + assertContextAndNext(context, next); + assertTrueFlag(onTurnCalled, 'onTurn'); + assertTrueFlag(onMessageReactionCalled, 'onMessageReaction', 'onTurn'); + assertFalseFlag(onReactionsRemovedCalled, 'onReactionsRemoved', 'onMessageReaction', 'onTurn'); + onReactionsRemovedCalled = true; + await next(); + }); + + processActivity({type: ActivityTypes.MessageReaction, reactionsRemoved: [{type: 'like'}]}, bot, done); + assertTrueFlag(onTurnCalled, 'onTurn', 'onMembersAdded'); + assertTrueFlag(onMessageReactionCalled, 'onMessageReaction'); + assertTrueFlag(onReactionsRemovedCalled, 'onReactionsRemoved'); + done(); + }); }); }); \ No newline at end of file diff --git a/libraries/botbuilder-core/tests/activityHandlerBase.test.js b/libraries/botbuilder-core/tests/activityHandlerBase.test.js new file mode 100644 index 0000000000..f6fd5a86dc --- /dev/null +++ b/libraries/botbuilder-core/tests/activityHandlerBase.test.js @@ -0,0 +1,304 @@ +const assert = require('assert'); +const { ActivityHandlerBase, ActivityTypes, TurnContext, TestAdapter } = require('../lib'); + +describe('ActivityHandlerBase', function() { + + const adapter = new TestAdapter(); + + async function processActivity(activity, bot, done) { + if (!activity) { + throw new Error('Missing activity'); + } + + if (!bot) { + throw new Error('Missing bot'); + } + + if (!done) { + throw new Error('Missing done'); + } + const context = new TurnContext(adapter, activity); + // Adding the catch with `done(error)` makes sure that the correct error is surfaced + await bot.run(context).catch(error => done(error)); + } + + let onTurnActivityCalled = false; + let onMessageCalled = false; + let onConversationUpdateActivityCalled = false; + let onMessageReactionCalled = false; + let onEventCalled = false; + let onUnrecognizedActivity = false; + + afterEach(function() { + onTurnActivityCalled = false; + onMessageCalled = false; + onConversationUpdateActivityCalled = false; + onMessageReactionCalled = false; + onEventCalled = false; + onUnrecognizedActivity = false; + }); + + it('should throw an error if context is not passed in', done => { + const bot = new ActivityHandlerBase(); + + bot.run().catch(error => { + if (error.message !== 'Missing TurnContext parameter') { + done(error); + } else { + done(); + } + }); + }); + + it('should throw an error if context.activity is falsey', done => { + const bot = new ActivityHandlerBase(); + + bot.run({}).catch(error => { + if (error.message !== 'TurnContext does not include an activity') { + done(error); + } else { + done(); + } + }); + }); + + it('should throw an error if context.activity.type is falsey', done => { + const bot = new ActivityHandlerBase(); + + bot.run({ activity: {} }).catch(error => { + if (error.message !== `Activity is missing it's type`) { + done(error); + } else { + done(); + } + }); + }); + + class OverrideOnTurnActivity extends ActivityHandlerBase { + async onTurnActivity(context) { + assert(context, 'context not found'); + super.onTurnActivity(context); + } + } + it('should call onActivity from run()', done => { + const bot = new OverrideOnTurnActivity(); + processActivity({ type: 'any' }, bot, done); + done(); + }); + + class UpdatedActivityHandler extends ActivityHandlerBase { + async onTurnActivity(context) { + assert(context, 'context not found'); + onTurnActivityCalled = true; + super.onTurnActivity(context); + } + + async onMessageActivity(context) { + assert(context, 'context not found'); + onMessageCalled = true; + } + + async onConversationUpdateActivity(context) { + assert(context, 'context not found'); + onConversationUpdateActivityCalled = true; + } + + async onMessageReactionActivity(context) { + assert(context, 'context not found'); + onMessageReactionCalled = true; + } + + async onEventActivity(context) { + assert(context, 'context not found'); + onEventCalled = true; + } + + async onUnrecognizedActivity(context) { + assert(context, 'context not found'); + onUnrecognizedActivity = true; + } + } + + it('should dispatch by ActivityType in onTurnActivity()', done => { + const bot = new UpdatedActivityHandler(); + + processActivity({ type: ActivityTypes.Message }, bot, done); + processActivity({type: ActivityTypes.ConversationUpdate}, bot, done); + processActivity({type: ActivityTypes.MessageReaction}, bot, done); + processActivity({type: ActivityTypes.Event}, bot, done); + processActivity({ type: 'unrecognized' }, bot, done); + + assert(onTurnActivityCalled, 'onTurnActivity was not called'); + assert(onMessageCalled, 'onMessageActivity was not called'); + assert(onConversationUpdateActivityCalled, 'onConversationUpdateActivity was not called'); + assert(onMessageReactionCalled, 'onMessageReactionActivity was not called'); + assert(onEventCalled, 'onEventActivity was not called'); + assert(onUnrecognizedActivity, 'onUnrecognizedActivity was not called'); + done(); + }); + + describe('onConversationUpdateActivity', () => { + class ConversationUpdateActivityHandler extends ActivityHandlerBase { + async onTurnActivity(context) { + assert(context, 'context not found'); + onTurnActivityCalled = true; + super.onTurnActivity(context); + } + + async onConversationUpdateActivity(context) { + assert(context, 'context not found'); + onConversationUpdateActivityCalled = true; + super.onConversationUpdateActivity(context); + } + + async onMembersAddedActivity(membersAdded, context) { + const value = context.activity.value; + if (value && value.skipSubtype) { + throw new Error('should not have reached onMembersAddedActivity'); + } + assert(context, 'context not found'); + assert(membersAdded, 'membersAdded not found'); + assert(membersAdded.length === 1, `unexpected number of membersAdded: ${membersAdded.length}`); + onMembersAddedActivityCalled = true; + } + + async onMembersRemovedActivity(membersRemoved, context) { + const value = context.activity.value; + if (value && value.skipSubtype) { + throw new Error('should not have reached onMembersRemovedActivity'); + } + assert(context, 'context not found'); + assert(membersRemoved, 'membersRemoved not found'); + assert(membersRemoved.length === 1, `unexpected number of membersRemoved: ${membersRemoved.length}`); + onMembersRemovedActivityCalled = true; + } + } + + let onTurnActivityCalled = false; + let onConversationUpdateActivityCalled = false; + let onMembersAddedActivityCalled = false; + let onMembersRemovedActivityCalled = false; + + afterEach(function() { + onTurnActivityCalled = false; + onConversationUpdateActivityCalled = false; + onMembersAddedActivityCalled = false; + onMembersRemovedActivityCalled = false; + }); + + function createConvUpdateActivity(recipientId, AddedOrRemoved, skipSubtype) { + const recipient = { id: recipientId }; + const activity = { type: ActivityTypes.ConversationUpdate, recipient, value: { }, ...AddedOrRemoved }; + if (skipSubtype) { + activity.value.skipSubtype = true; + } + return activity; + } + + it(`should call onMembersAddedActivity if the id of the member added does not match the recipient's id`, done => { + const bot = new ConversationUpdateActivityHandler(); + const activity = createConvUpdateActivity('bot', { membersAdded: [ { id: 'user' } ] }); + processActivity(activity, bot, done); + assert(onTurnActivityCalled, 'onTurnActivity was not called'); + assert(onConversationUpdateActivityCalled, 'onConversationUpdateActivity was not called'); + assert(onMembersAddedActivityCalled, 'onMembersAddedActivity was not called'); + done(); + }); + + it(`should call onMembersRemovedActivity if the id of the member removed does not match the recipient's id`, done => { + const bot = new ConversationUpdateActivityHandler(); + const activity = createConvUpdateActivity('bot', { membersRemoved: [ { id: 'user' } ] }); + processActivity(activity, bot, done); + assert(onTurnActivityCalled, 'onTurnActivity was not called'); + assert(onConversationUpdateActivityCalled, 'onConversationUpdateActivity was not called'); + assert(onMembersRemovedActivityCalled, 'onMembersRemovedActivity was not called'); + done(); + }); + + it(`should not call onMembersAddedActivity if the id of the member added matches the recipient's id`, done => { + const bot = new ConversationUpdateActivityHandler(); + const activity = createConvUpdateActivity('bot', { membersAdded: [ { id: 'bot' } ] }, true); + processActivity(activity, bot, done); + assert(onTurnActivityCalled, 'onTurnActivity was not called'); + assert(onConversationUpdateActivityCalled, 'onConversationUpdateActivity was not called'); + done(); + }); + + it(`should not call onMembersRemovedActivity if the id of the member removed matches the recipient's id`, done => { + const bot = new ConversationUpdateActivityHandler(); + const activity = createConvUpdateActivity('bot', { membersRemoved: [ { id: 'bot' } ] }, true); + processActivity(activity, bot, done); + assert(onTurnActivityCalled, 'onTurnActivity was not called'); + assert(onConversationUpdateActivityCalled, 'onConversationUpdateActivity was not called'); + done(); + }); + }); + + describe('onMessageReaction', () => { + class MessageReactionActivityHandler extends ActivityHandlerBase { + async onTurnActivity(context) { + assert(context, 'context not found'); + onTurnActivityCalled = true; + super.onTurnActivity(context); + } + + async onMessageReactionActivity(context) { + assert(context, 'context not found'); + onMessageReactionActivityCalled = true; + super.onMessageReactionActivity(context); + } + + async onReactionsAddedActivity(reactionsAdded, context) { + assert(context, 'context not found'); + assert(reactionsAdded, 'membersAdded not found'); + assert(reactionsAdded.length === 1, `unexpected number of reactionsAdded: ${reactionsAdded.length}`); + onReactionsAddedActivityCalled = true; + } + + async onReactionsRemovedActivity(reactionsRemoved, context) { + assert(context, 'context not found'); + assert(reactionsRemoved, 'reactionsRemoved not found'); + assert(reactionsRemoved.length === 1, `unexpected number of reactionsRemoved: ${reactionsRemoved.length}`); + onReactionsRemovedActivityCalled = true; + } + } + + let onTurnActivityCalled = false; + let onMessageReactionActivityCalled = false; + let onReactionsAddedActivityCalled = false; + let onReactionsRemovedActivityCalled = false; + + afterEach(function() { + onTurnActivityCalled = false; + onMessageReactionActivityCalled = false; + onReactionsAddedActivityCalled = false; + onReactionsRemovedActivityCalled = false; + }); + + function createMsgReactActivity(recipientId, AddedOrRemoved) { + const recipient = { id: recipientId }; + const activity = { type: ActivityTypes.MessageReaction, recipient, ...AddedOrRemoved }; + return activity; + } + + it(`should call onReactionsAddedActivity if reactionsAdded exists and reactionsAdded.length > 0`, done => { + const bot = new MessageReactionActivityHandler(); + const activity = createMsgReactActivity('bot', { reactionsAdded: [ { type: 'like' } ] }); + processActivity(activity, bot, done); + assert(onTurnActivityCalled, 'onTurnActivity was not called'); + assert(onMessageReactionActivityCalled, 'onMessageReactionActivity was not called'); + assert(onReactionsAddedActivityCalled, 'onReactionsAddedActivity was not called'); + done(); + }); + + it(`should call onReactionsRemovedActivity if reactionsRemoved exists and reactionsRemoved.length > 0`, done => { + const bot = new MessageReactionActivityHandler(); + const activity = createMsgReactActivity('bot', { reactionsRemoved: [ { type: 'like' } ] }); + processActivity(activity, bot, done); + assert(onTurnActivityCalled, 'onTurnActivity was not called'); + assert(onMessageReactionActivityCalled, 'onMessageReactionActivity was not called'); + assert(onReactionsRemovedActivityCalled, 'onReactionsRemovedActivity was not called'); + done(); + }); + }); +}); \ No newline at end of file