Repository: Yoctol/bottender
Pull request #603 (2019-12-26 08:50:14)

feat(router): add messenger routes
Add messenger routes:

```js
const { router, messenger } = require('bottender/router');

function Action() {
  // ...
}

function App() {
  return router([
    messenger.message(Action),
    messenger.accountLinking.linked(Action),
    messenger.accountLinking.unlinked(Action),
    messenger.accountLinking(Action),
    messenger.checkoutUpdate(Action),
    messenger.delivery(Action),
    messenger.echo(Action),
    messenger.gamePlay(Action),
    messenger.passThreadControl(Action),
    messenger.takeThreadControl(Action),
    messenger.requestThreadControl(Action),
    messenger.appRoles(Action),
    messenger.optin(Action),
    messenger.payment(Action),
    messenger.policyEnforcement(Action),
    messenger.postback(Action),
    messenger.preCheckout(Action),
    messenger.read(Action),
    messenger.referral(Action),
    messenger.standby(Action),
    messenger(Action),
  ]);
}
```


codecov-io: # [Codecov](https://codecov.io/gh/Yoctol/bottender/pull/603?src=pr&el=h1) Report
> Merging [#603](https://codecov.io/gh/Yoctol/bottender/pull/603?src=pr&el=desc) into [release/1.1](https://codecov.io/gh/Yoctol/bottender/commit/9f6466095c7b84b6982fe785a70470feed85e11c?src=pr&el=desc) will **increase** coverage by `0.33%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/Yoctol/bottender/pull/603/graphs/tree.svg?width=650&token=JX3EGtxYH3&height=150&src=pr)](https://codecov.io/gh/Yoctol/bottender/pull/603?src=pr&el=tree)

```diff
@@               Coverage Diff               @@
##           release/1.1     #603      +/-   ##
===============================================
+ Coverage        80.11%   80.44%   +0.33%     
===============================================
  Files               91       92       +1     
  Lines             4098     4168      +70     
  Branches           998     1022      +24     
===============================================
+ Hits              3283     3353      +70     
  Misses             775      775              
  Partials            40       40
```


| [Impacted Files](https://codecov.io/gh/Yoctol/bottender/pull/603?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/bottender/src/messenger/routes.ts](https://codecov.io/gh/Yoctol/bottender/pull/603/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9tZXNzZW5nZXIvcm91dGVzLnRz) | `100% <100%> (ø)` | |
| [packages/bottender/src/messenger/MessengerEvent.ts](https://codecov.io/gh/Yoctol/bottender/pull/603/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9tZXNzZW5nZXIvTWVzc2VuZ2VyRXZlbnQudHM=) | `98.41% <100%> (+0.06%)` | :arrow_up: |
| [packages/bottender/src/router/index.ts](https://codecov.io/gh/Yoctol/bottender/pull/603/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9yb3V0ZXIvaW5kZXgudHM=) | `88.7% <100%> (+0.18%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/Yoctol/bottender/pull/603?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/Yoctol/bottender/pull/603?src=pr&el=footer). Last update [9f64660...f20701d](https://codecov.io/gh/Yoctol/bottender/pull/603?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/packages/bottender/src/messenger/MessengerEvent.ts b/packages/bottender/src/messenger/MessengerEvent.ts
index 007620c4..1862461c 100644
--- a/packages/bottender/src/messenger/MessengerEvent.ts
+++ b/packages/bottender/src/messenger/MessengerEvent.ts
@@ -45,24 +45,32 @@ type Tag = {
   source: string;
 };
 
+type ReplyTo = {
+  mid: string;
+};
+
 export type Message = {
+  mid: string;
   isEcho?: boolean;
   text?: string;
   stickerId?: number;
   quickReply?: QuickReply;
   attachments?: Attachment[];
   tags?: Tag[];
+  replyTo?: ReplyTo;
+  appId?: number;
+  metadata?: string;
 };
 
 export type Delivery = {
   mids: string[];
   watermark: number;
-  seq: number;
+  seq?: number;
 };
 
 export type Read = {
   watermark: number;
-  seq: number;
+  seq?: number;
 };
 
 export type Referral = {
@@ -73,7 +81,8 @@ export type Referral = {
 };
 
 export type Postback = {
-  payload: string;
+  title: string;
+  payload?: string;
   referral?: Referral;
 };
 
@@ -88,6 +97,7 @@ export type GamePlay = {
 
 export type Optin = {
   ref: string;
+  userRef?: string;
 };
 
 export type Payment = {
@@ -161,6 +171,10 @@ export type BrandedCamera = {
   event: string;
 };
 
+export type AccountLinking =
+  | { status: 'linked'; authorizationCode: string }
+  | { status: 'unlinked' };
+
 export type MessengerRawEvent = {
   sender?: Sender;
   recipient?: Recipient;
@@ -181,6 +195,7 @@ export type MessengerRawEvent = {
   requestThreadControl?: RequestThreadControl;
   referral?: Referral;
   brandedCamera?: BrandedCamera;
+  accountLinking?: AccountLinking;
 };
 
 type MessengerEventOptions = {
@@ -615,10 +630,10 @@ export default class MessengerEvent implements Event<MessengerRawEvent> {
    */
   get payload(): string | null {
     if (!!this.postback && this.isPayload) {
-      return this.postback.payload;
+      return this.postback.payload || null;
     }
     if (!!this.quickReply && this.isPayload) {
-      return this.quickReply.payload;
+      return this.quickReply.payload || null;
     }
     return null;
   }
@@ -827,4 +842,26 @@ export default class MessengerEvent implements Event<MessengerRawEvent> {
     }
     return (this._rawEvent as any).brandedCamera;
   }
+
+  /**
+   * Determine if the event is a account_linking event.
+   *
+   */
+  get isAccountLinking(): boolean {
+    return (
+      !!this._rawEvent.accountLinking &&
+      typeof this._rawEvent.accountLinking === 'object'
+    );
+  }
+
+  /**
+   * The accountLinking object from Messenger event.
+   *
+   */
+  get accountLinking(): AccountLinking | null {
+    if (!this.isAccountLinking) {
+      return null;
+    }
+    return (this._rawEvent as any).accountLinking;
+  }
 }
diff --git a/packages/bottender/src/messenger/routes.ts b/packages/bottender/src/messenger/routes.ts
new file mode 100644
index 00000000..a602e913
--- /dev/null
+++ b/packages/bottender/src/messenger/routes.ts
@@ -0,0 +1,286 @@
+import Context from '../context/Context';
+import { Action, Client, Event } from '../types';
+import { RoutePredicate, route } from '../router';
+
+type Route = <C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) => {
+  predicate: RoutePredicate<C, E>;
+  action: Action<C, E>;
+};
+
+type Messenger = Route & {
+  message: Route;
+  accountLinking: Route & {
+    linked: Route;
+    unlinked: Route;
+  };
+  checkoutUpdate: Route;
+  delivery: Route;
+  echo: Route;
+  gamePlay: Route;
+  passThreadControl: Route;
+  takeThreadControl: Route;
+  requestThreadControl: Route;
+  appRoles: Route;
+  optin: Route;
+  payment: Route;
+  policyEnforcement: Route;
+  postback: Route;
+  preCheckout: Route;
+  read: Route;
+  referral: Route;
+  standby: Route;
+};
+
+const messenger: Messenger = <C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) => {
+  return route(context => context.platform === 'messenger', action);
+};
+
+function message<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isMessage,
+    action
+  );
+}
+
+messenger.message = message;
+
+function accountLinking<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isAccountLinking,
+    action
+  );
+}
+
+messenger.accountLinking = accountLinking;
+
+function accountLinkingLinked<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' &&
+      context.event.isAccountLinking &&
+      context.event.accountLinking.status === 'linked',
+    action
+  );
+}
+
+accountLinking.linked = accountLinkingLinked;
+
+function accountLinkingUnlinked<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' &&
+      context.event.isAccountLinking &&
+      context.event.accountLinking.status === 'unlinked',
+    action
+  );
+}
+
+accountLinking.unlinked = accountLinkingUnlinked;
+
+function checkoutUpdate<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isCheckoutUpdate,
+    action
+  );
+}
+
+messenger.checkoutUpdate = checkoutUpdate;
+
+function delivery<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isDelivery,
+    action
+  );
+}
+
+messenger.delivery = delivery;
+
+function echo<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isEcho,
+    action
+  );
+}
+
+messenger.echo = echo;
+
+function gamePlay<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isGamePlay,
+    action
+  );
+}
+
+messenger.gamePlay = gamePlay;
+
+function passThreadControl<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isPassThreadControl,
+    action
+  );
+}
+
+messenger.passThreadControl = passThreadControl;
+
+function takeThreadControl<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isTakeThreadControl,
+    action
+  );
+}
+
+messenger.takeThreadControl = takeThreadControl;
+
+function requestThreadControl<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isRequestThreadControl,
+    action
+  );
+}
+
+messenger.requestThreadControl = requestThreadControl;
+
+function appRoles<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isAppRoles,
+    action
+  );
+}
+
+messenger.appRoles = appRoles;
+
+function optin<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isOptin,
+    action
+  );
+}
+
+messenger.optin = optin;
+
+function payment<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isPayment,
+    action
+  );
+}
+
+messenger.payment = payment;
+
+function policyEnforcement<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isPolicyEnforcement,
+    action
+  );
+}
+
+messenger.policyEnforcement = policyEnforcement;
+
+function postback<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isPostback,
+    action
+  );
+}
+
+messenger.postback = postback;
+
+function preCheckout<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isPreCheckout,
+    action
+  );
+}
+
+messenger.preCheckout = preCheckout;
+
+function read<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isRead,
+    action
+  );
+}
+
+messenger.read = read;
+
+function referral<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isReferral,
+    action
+  );
+}
+
+messenger.referral = referral;
+
+function standby<C extends Client = any, E extends Event = any>(
+  action: Action<C, E>
+) {
+  return route(
+    (context: Context<C, E>) =>
+      context.platform === 'messenger' && context.event.isStandby,
+    action
+  );
+}
+
+messenger.standby = standby;
+
+export default messenger;
diff --git a/packages/bottender/src/router/index.ts b/packages/bottender/src/router/index.ts
index 48b6e030..df5201b4 100644
--- a/packages/bottender/src/router/index.ts
+++ b/packages/bottender/src/router/index.ts
@@ -1,5 +1,6 @@
 import Context from '../context/Context';
 import line from '../line/routes';
+import messenger from '../messenger/routes';
 import { Action, Client, Event, Props } from '../types';
 
 type MatchPattern = string | Array<string> | RegExp;
@@ -192,4 +193,4 @@ function platform<C extends Client = any, E extends Event = any>(
 
 export default router;
 
-export { router, route, text, payload, platform, line };
+export { router, route, text, payload, platform, line, messenger };


Test patch:
diff --git a/packages/bottender/src/line/__tests__/routes.spec.ts b/packages/bottender/src/line/__tests__/routes.spec.ts
index 692014f2..691c3831 100644
--- a/packages/bottender/src/line/__tests__/routes.spec.ts
+++ b/packages/bottender/src/line/__tests__/routes.spec.ts
@@ -306,14 +306,14 @@ class TestContext extends Context<any, any> {
 }
 
 describe('#line', () => {
-  it('should call action when it receives line event', async () => {
+  it('should call action when it receives a line event', async () => {
     await expectRouteMatchLineEvent({
       route: line(Action),
       event: lineEventTextMessage,
     });
   });
 
-  it('should not call action when it receives non-line event', async () => {
+  it('should not call action when it receives a non-line event', async () => {
     await expectRouteNotMatchContext({
       route: line(Action),
       context: new TestContext({
@@ -331,7 +331,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-message event', async () => {
+    it('should not call action when it receives a non-message event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.message(Action),
         event: lineEventFollow,
@@ -347,7 +347,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-follow event', async () => {
+    it('should not call action when it receives a non-follow event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.follow(Action),
         event: lineEventUnfollow,
@@ -363,7 +363,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-unfollow event', async () => {
+    it('should not call action when it receives a non-unfollow event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.unfollow(Action),
         event: lineEventFollow,
@@ -379,7 +379,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-join event', async () => {
+    it('should not call action when it receives a non-join event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.join(Action),
         event: lineEventLeave,
@@ -395,7 +395,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-leave event', async () => {
+    it('should not call action when it receives a non-leave event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.leave(Action),
         event: lineEventJoin,
@@ -411,7 +411,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-memberJoined event', async () => {
+    it('should not call action when it receives a non-memberJoined event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.memberJoined(Action),
         event: lineEventMemberLeft,
@@ -427,7 +427,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-memberLeft event', async () => {
+    it('should not call action when it receives a non-memberLeft event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.memberLeft(Action),
         event: lineEventMemberJoined,
@@ -443,7 +443,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-postback event', async () => {
+    it('should not call action when it receives a non-postback event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.postback(Action),
         event: lineEventTextMessage,
@@ -459,7 +459,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-beacon event', async () => {
+    it('should not call action when it receives a non-beacon event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.beacon(Action),
         event: lineEventTextMessage,
@@ -474,7 +474,7 @@ describe('#line', () => {
         });
       });
 
-      it('should not call action when it receives non-beacon.enter event', async () => {
+      it('should not call action when it receives a non-beacon.enter event', async () => {
         await expectRouteNotMatchLineEvent({
           route: line.beacon.enter(Action),
           event: lineEventBeaconBanner,
@@ -490,7 +490,7 @@ describe('#line', () => {
         });
       });
 
-      it('should not call action when it receives non-beacon.banner event', async () => {
+      it('should not call action when it receives a non-beacon.banner event', async () => {
         await expectRouteNotMatchLineEvent({
           route: line.beacon.banner(Action),
           event: lineEventBeaconStay,
@@ -506,7 +506,7 @@ describe('#line', () => {
         });
       });
 
-      it('should not call action when it receives non-beacon.stay event', async () => {
+      it('should not call action when it receives a non-beacon.stay event', async () => {
         await expectRouteNotMatchLineEvent({
           route: line.beacon.stay(Action),
           event: lineEventBeaconEnter,
@@ -523,7 +523,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-accountLink event', async () => {
+    it('should not call action when it receives a non-accountLink event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.accountLink(Action),
         event: lineEventTextMessage,
@@ -539,7 +539,7 @@ describe('#line', () => {
       });
     });
 
-    it('should not call action when it receives non-things event', async () => {
+    it('should not call action when it receives a non-things event', async () => {
       await expectRouteNotMatchLineEvent({
         route: line.things(Action),
         event: lineEventTextMessage,
@@ -554,7 +554,7 @@ describe('#line', () => {
         });
       });
 
-      it('should not call action when it receives non-things.link event', async () => {
+      it('should not call action when it receives a non-things.link event', async () => {
         await expectRouteNotMatchLineEvent({
           route: line.things.link(Action),
           event: lineEventThingsUnlink,
@@ -570,7 +570,7 @@ describe('#line', () => {
         });
       });
 
-      it('should not call action when it receives non-things.unlink event', async () => {
+      it('should not call action when it receives a non-things.unlink event', async () => {
         await expectRouteNotMatchLineEvent({
           route: line.things.unlink(Action),
           event: lineEventThingsLink,
@@ -586,7 +586,7 @@ describe('#line', () => {
         });
       });
 
-      it('should not call action when it receives non-things.scenarioResult event', async () => {
+      it('should not call action when it receives a non-things.scenarioResult event', async () => {
         await expectRouteNotMatchLineEvent({
           route: line.things.scenarioResult(Action),
           event: lineEventThingsLink,
diff --git a/packages/bottender/src/messenger/__tests__/MessengerEvent.spec.ts b/packages/bottender/src/messenger/__tests__/MessengerEvent.spec.ts
index 6ba90244..ffff315c 100644
--- a/packages/bottender/src/messenger/__tests__/MessengerEvent.spec.ts
+++ b/packages/bottender/src/messenger/__tests__/MessengerEvent.spec.ts
@@ -583,6 +583,33 @@ const brandedCamera = {
   },
 };
 
+const accountLinkingLinked = {
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1469111400000,
+  accountLinking: {
+    status: 'linked',
+    authorizationCode: 'PASS_THROUGH_AUTHORIZATION_CODE',
+  },
+};
+
+const accountLinkingUnlinked = {
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1469111400000,
+  accountLinking: {
+    status: 'unlinked',
+  },
+};
+
 it('#rawEvent', () => {
   expect(new MessengerEvent(textMessage).rawEvent).toEqual(textMessage);
   expect(new MessengerEvent(imageMessage).rawEvent).toEqual(imageMessage);
@@ -595,6 +622,12 @@ it('#rawEvent', () => {
   expect(new MessengerEvent(echoMessage).rawEvent).toEqual(echoMessage);
   expect(new MessengerEvent(postback).rawEvent).toEqual(postback);
   expect(new MessengerEvent(payment).rawEvent).toEqual(payment);
+  expect(new MessengerEvent(accountLinkingLinked).rawEvent).toEqual(
+    accountLinkingLinked
+  );
+  expect(new MessengerEvent(accountLinkingUnlinked).rawEvent).toEqual(
+    accountLinkingUnlinked
+  );
 });
 
 it('#isMessage', () => {
@@ -608,6 +641,8 @@ it('#isMessage', () => {
   expect(new MessengerEvent(echoMessage).isMessage).toEqual(true);
   expect(new MessengerEvent(postback).isMessage).toEqual(false);
   expect(new MessengerEvent(payment).isMessage).toEqual(false);
+  expect(new MessengerEvent(accountLinkingLinked).isMessage).toEqual(false);
+  expect(new MessengerEvent(accountLinkingUnlinked).isMessage).toEqual(false);
 });
 
 it('#message', () => {
@@ -897,6 +932,8 @@ it('#isDelivery', () => {
   expect(new MessengerEvent(echoMessage).isDelivery).toEqual(false);
   expect(new MessengerEvent(postback).isDelivery).toEqual(false);
   expect(new MessengerEvent(payment).isDelivery).toEqual(false);
+  expect(new MessengerEvent(accountLinkingLinked).isDelivery).toEqual(false);
+  expect(new MessengerEvent(accountLinkingUnlinked).isDelivery).toEqual(false);
 });
 
 it('#delivery', () => {
@@ -910,6 +947,8 @@ it('#delivery', () => {
   expect(new MessengerEvent(echoMessage).delivery).toEqual(null);
   expect(new MessengerEvent(postback).delivery).toEqual(null);
   expect(new MessengerEvent(payment).delivery).toEqual(null);
+  expect(new MessengerEvent(accountLinkingLinked).delivery).toEqual(null);
+  expect(new MessengerEvent(accountLinkingUnlinked).delivery).toEqual(null);
 });
 
 it('#isRead', () => {
@@ -919,6 +958,8 @@ it('#isRead', () => {
   expect(new MessengerEvent(echoMessage).isRead).toEqual(false);
   expect(new MessengerEvent(postback).isRead).toEqual(false);
   expect(new MessengerEvent(payment).isRead).toEqual(false);
+  expect(new MessengerEvent(accountLinkingLinked).isRead).toEqual(false);
+  expect(new MessengerEvent(accountLinkingUnlinked).isRead).toEqual(false);
 });
 
 it('#read', () => {
@@ -931,6 +972,8 @@ it('#read', () => {
   expect(new MessengerEvent(echoMessage).read).toEqual(null);
   expect(new MessengerEvent(postback).read).toEqual(null);
   expect(new MessengerEvent(payment).read).toEqual(null);
+  expect(new MessengerEvent(accountLinkingLinked).read).toEqual(null);
+  expect(new MessengerEvent(accountLinkingUnlinked).read).toEqual(null);
 });
 
 it('#isEcho', () => {
@@ -949,6 +992,8 @@ it('#isPostback', () => {
   expect(new MessengerEvent(echoMessage).isPostback).toEqual(false);
   expect(new MessengerEvent(postback).isPostback).toEqual(true);
   expect(new MessengerEvent(payment).isPostback).toEqual(false);
+  expect(new MessengerEvent(accountLinkingLinked).isPostback).toEqual(false);
+  expect(new MessengerEvent(accountLinkingUnlinked).isPostback).toEqual(false);
 });
 
 it('#postback', () => {
@@ -964,6 +1009,8 @@ it('#isPayload', () => {
   expect(new MessengerEvent(echoMessage).isPayload).toEqual(false);
   expect(new MessengerEvent(quickReplyMessage).isPayload).toEqual(true);
   expect(new MessengerEvent(postback).isPayload).toEqual(true);
+  expect(new MessengerEvent(accountLinkingLinked).isPayload).toEqual(false);
+  expect(new MessengerEvent(accountLinkingUnlinked).isPayload).toEqual(false);
 });
 
 it('#postback', () => {
@@ -985,6 +1032,8 @@ it('#isGamePlay', () => {
   expect(new MessengerEvent(gamePlayWithNonValidPayload).isGamePlay).toEqual(
     true
   );
+  expect(new MessengerEvent(accountLinkingLinked).isGamePlay).toEqual(false);
+  expect(new MessengerEvent(accountLinkingUnlinked).isGamePlay).toEqual(false);
 });
 
 it('#gamePlay', () => {
@@ -1012,6 +1061,8 @@ it('#gamePlay', () => {
     score: 99,
     payload: 'SOME_STRING',
   });
+  expect(new MessengerEvent(accountLinkingLinked).gamePlay).toEqual(null);
+  expect(new MessengerEvent(accountLinkingUnlinked).gamePlay).toEqual(null);
 });
 
 it('#isOptin', () => {
@@ -1022,6 +1073,8 @@ it('#isOptin', () => {
   expect(new MessengerEvent(quickReplyMessage).isOptin).toEqual(false);
   expect(new MessengerEvent(postback).isOptin).toEqual(false);
   expect(new MessengerEvent(optin).isOptin).toEqual(true);
+  expect(new MessengerEvent(accountLinkingLinked).isOptin).toEqual(false);
+  expect(new MessengerEvent(accountLinkingUnlinked).isOptin).toEqual(false);
 });
 
 it('#optin', () => {
@@ -1034,6 +1087,8 @@ it('#optin', () => {
   expect(new MessengerEvent(optin).optin).toEqual({
     ref: 'PASS_THROUGH_PARAM',
   });
+  expect(new MessengerEvent(accountLinkingLinked).optin).toEqual(null);
+  expect(new MessengerEvent(accountLinkingUnlinked).optin).toEqual(null);
 });
 
 it('#isPayment', () => {
@@ -1044,6 +1099,8 @@ it('#isPayment', () => {
   expect(new MessengerEvent(quickReplyMessage).isPayment).toEqual(false);
   expect(new MessengerEvent(postback).isPayment).toEqual(false);
   expect(new MessengerEvent(payment).isPayment).toEqual(true);
+  expect(new MessengerEvent(accountLinkingLinked).isPayment).toEqual(false);
+  expect(new MessengerEvent(accountLinkingUnlinked).isPayment).toEqual(false);
 });
 
 it('#payment', () => {
@@ -1079,6 +1136,12 @@ it('#isCheckoutUpdate', () => {
   expect(new MessengerEvent(quickReplyMessage).isCheckoutUpdate).toEqual(false);
   expect(new MessengerEvent(postback).isCheckoutUpdate).toEqual(false);
   expect(new MessengerEvent(checkoutUpdate).isCheckoutUpdate).toEqual(true);
+  expect(new MessengerEvent(accountLinkingLinked).isCheckoutUpdate).toEqual(
+    false
+  );
+  expect(new MessengerEvent(accountLinkingUnlinked).isCheckoutUpdate).toEqual(
+    false
+  );
 });
 
 it('#checkoutUpdate', () => {
@@ -1100,6 +1163,10 @@ it('#checkoutUpdate', () => {
       postalCode: '94025',
     },
   });
+  expect(new MessengerEvent(accountLinkingLinked).checkoutUpdate).toEqual(null);
+  expect(new MessengerEvent(accountLinkingUnlinked).checkoutUpdate).toEqual(
+    null
+  );
 });
 
 it('#isPreCheckout', () => {
@@ -1110,6 +1177,10 @@ it('#isPreCheckout', () => {
   expect(new MessengerEvent(quickReplyMessage).isPreCheckout).toEqual(false);
   expect(new MessengerEvent(postback).isPreCheckout).toEqual(false);
   expect(new MessengerEvent(preCheckout).isPreCheckout).toEqual(true);
+  expect(new MessengerEvent(accountLinkingLinked).isPreCheckout).toEqual(false);
+  expect(new MessengerEvent(accountLinkingUnlinked).isPreCheckout).toEqual(
+    false
+  );
 });
 
 it('#preCheckout', () => {
@@ -1138,6 +1209,8 @@ it('#preCheckout', () => {
       amount: '2.70',
     },
   });
+  expect(new MessengerEvent(accountLinkingLinked).preCheckout).toEqual(null);
+  expect(new MessengerEvent(accountLinkingUnlinked).preCheckout).toEqual(null);
 });
 
 it('#isPolicyEnforcement', () => {
@@ -1351,3 +1424,34 @@ it('#brandedCamera', () => {
     event: 'dismiss',
   });
 });
+
+it('#isAccountLinking', () => {
+  expect(new MessengerEvent(textMessage).isAccountLinking).toEqual(false);
+  expect(new MessengerEvent(delivery).isAccountLinking).toEqual(false);
+  expect(new MessengerEvent(read).isAccountLinking).toEqual(false);
+  expect(new MessengerEvent(echoMessage).isAccountLinking).toEqual(false);
+  expect(new MessengerEvent(postback).isAccountLinking).toEqual(false);
+  expect(new MessengerEvent(payment).isAccountLinking).toEqual(false);
+  expect(new MessengerEvent(accountLinkingLinked).isAccountLinking).toEqual(
+    true
+  );
+  expect(new MessengerEvent(accountLinkingUnlinked).isAccountLinking).toEqual(
+    true
+  );
+});
+
+it('#accountLinking', () => {
+  expect(new MessengerEvent(textMessage).accountLinking).toEqual(null);
+  expect(new MessengerEvent(delivery).accountLinking).toEqual(null);
+  expect(new MessengerEvent(read).accountLinking).toEqual(null);
+  expect(new MessengerEvent(echoMessage).accountLinking).toEqual(null);
+  expect(new MessengerEvent(postback).accountLinking).toEqual(null);
+  expect(new MessengerEvent(payment).accountLinking).toEqual(null);
+  expect(new MessengerEvent(accountLinkingLinked).accountLinking).toEqual({
+    authorizationCode: 'PASS_THROUGH_AUTHORIZATION_CODE',
+    status: 'linked',
+  });
+  expect(new MessengerEvent(accountLinkingUnlinked).accountLinking).toEqual({
+    status: 'unlinked',
+  });
+});
diff --git a/packages/bottender/src/messenger/__tests__/routes.spec.ts b/packages/bottender/src/messenger/__tests__/routes.spec.ts
new file mode 100644
index 00000000..dc2be924
--- /dev/null
+++ b/packages/bottender/src/messenger/__tests__/routes.spec.ts
@@ -0,0 +1,723 @@
+import Context from '../../context/Context';
+import MessengerContext from '../MessengerContext';
+import MessengerEvent from '../MessengerEvent';
+import messenger from '../routes';
+import router from '../../router';
+import { run } from '../../bot/Bot';
+
+const messengerEventTextMessage = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  message: {
+    mid: 'mid.1457764197618:41d102a3e1ae206a38',
+    text: 'hello, world!',
+  },
+});
+
+const messengerEventAccountLinkingLinked = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  accountLinking: {
+    status: 'linked',
+    authorizationCode: 'PASS_THROUGH_AUTHORIZATION_CODE',
+  },
+});
+
+const messengerEventAccountLinkingUnlinked = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  accountLinking: {
+    status: 'unlinked',
+  },
+});
+
+const messengerEventCheckoutUpdate = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  checkoutUpdate: {
+    payload: 'DEVELOPER_DEFINED_PAYLOAD',
+    shippingAddress: {
+      id: 10105655000959552,
+      country: 'US',
+      city: 'MENLO PARK',
+      street1: '1 Hacker Way',
+      street2: '',
+      state: 'CA',
+      postalCode: '94025',
+    },
+  },
+});
+
+const messengerEventDelivery = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  delivery: {
+    mids: ['mid.1458668856218:ed81099e15d3f4f233'],
+    watermark: 1458668856253,
+  },
+});
+
+const messengerEventEcho = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  message: {
+    isEcho: true,
+    appId: 1517776481860111,
+    metadata: '<DEVELOPER_DEFINED_METADATA_STRING>',
+    mid: 'mid.1457764197618:41d102a3e1ae206a38',
+  },
+});
+
+const messengerEventGamePlay = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  gamePlay: {
+    gameId: '<GAME-APP-ID>',
+    playerId: '<PLAYER-ID>',
+    contextType: 'SOLO',
+    contextId: '<CONTEXT-ID>',
+    score: 0,
+    payload: '<PAYLOAD>',
+  },
+});
+
+const messengerEventPassThreadControl = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  passThreadControl: {
+    newOwnerAppId: '123456789',
+    metadata: 'Additional content that the caller wants to set',
+  },
+});
+
+const messengerEventTakeThreadControl = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  takeThreadControl: {
+    previousOwnerAppId: '123456789',
+    metadata: 'additional content that the caller wants to set',
+  },
+});
+
+const messengerEventRequestThreadControl = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  requestThreadControl: {
+    requestedOwnerAppId: 123456789,
+    metadata: 'additional content that the caller wants to set',
+  },
+});
+
+const messengerEventAppRoles = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  appRoles: {
+    '123456789': ['primary_receiver'],
+  },
+});
+
+const messengerEventOptin = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  optin: {
+    ref: '<PASS_THROUGH_PARAM>',
+    userRef: '<REF_FROM_CHECKBOX_PLUGIN>',
+  },
+});
+
+const messengerEventPayment = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  payment: {
+    payload: 'DEVELOPER_DEFINED_PAYLOAD',
+    requestedUserInfo: {
+      shippingAddress: {},
+      contactName: 'Peter Chang',
+      contactEmail: 'peter@anemail.com',
+      contactPhone: '+15105551234',
+    },
+    paymentCredential: {
+      providerType: 'paypal',
+      chargeId: 'ch_18tmdBEoNIH3FPJHa60ep123',
+      fbPaymentId: '123456789',
+    },
+    amount: {
+      currency: 'USD',
+      amount: '29.62',
+    },
+    shippingOptionId: '123',
+  },
+});
+
+const messengerEventPolicyEnforcement = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  'policy-enforcement': {
+    action: 'block',
+    reason:
+      'The bot violated our Platform Policies (https://developers.facebook.com/policy/#messengerplatform). Common violations include sending out excessive spammy messages or being non-functional.',
+  },
+});
+
+const messengerEventPostback = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  postback: {
+    title: '<TITLE_FOR_THE_CTA>',
+    payload: '<USER_DEFINED_PAYLOAD>',
+    referral: {
+      ref: '<USER_DEFINED_REFERRAL_PARAM>',
+      source: '<SHORTLINK>',
+      type: 'OPEN_THREAD',
+    },
+  },
+});
+
+const messengerEventPreCheckout = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  preCheckout: {
+    payload: 'xyz',
+    requestedUserInfo: {
+      shippingAddress: {
+        name: 'Tao Jiang',
+        street1: '600 Edgewater Blvd',
+        street2: '',
+        city: 'Foster City',
+        state: 'CA',
+        country: 'US',
+        postalCode: '94404',
+      },
+      contactName: 'Tao Jiang',
+    },
+    amount: {
+      currency: 'USD',
+      amount: '2.70',
+    },
+  },
+});
+
+const messengerEventRead = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  read: {
+    watermark: 1458668856253,
+  },
+});
+
+const messengerEventReferral = new MessengerEvent({
+  sender: {
+    id: '1476077422222289',
+  },
+  recipient: {
+    id: '707356222221168',
+  },
+  timestamp: 1458692752478,
+  referral: {
+    ref: '<REF_DATA_PASSED_IN_M.ME_PARAM>',
+    source: 'SHORTLINK',
+    type: 'OPEN_THREAD',
+  },
+});
+
+const messengerEventStandby = new MessengerEvent(
+  {
+    sender: {
+      id: '1476077422222289',
+    },
+    recipient: {
+      id: '707356222221168',
+    },
+    timestamp: 1458692752478,
+    message: {
+      mid: 'mid.1457764197618:41d102a3e1ae206a38',
+      text: 'hello, world!',
+    },
+  },
+  { isStandby: true }
+);
+
+async function Action(context) {
+  await context.sendText('hello');
+}
+
+async function expectRouteMatchContext({ route, context }) {
+  const Router = router([route]);
+
+  const app = run(Router);
+
+  context.sendText = jest.fn();
+
+  await app(context);
+
+  expect(context.sendText).toBeCalledWith('hello');
+}
+
+async function expectRouteMatchLineEvent({ route, event }) {
+  const context = new MessengerContext({
+    client: {} as any,
+    event,
+  });
+
+  await expectRouteMatchContext({
+    route,
+    context,
+  });
+}
+
+async function expectRouteNotMatchContext({ route, context }) {
+  const Router = router([route]);
+
+  const app = run(Router);
+
+  context.sendText = jest.fn();
+
+  await app(context);
+
+  expect(context.sendText).not.toBeCalledWith('hello');
+}
+
+async function expectRouteNotMatchLineEvent({ route, event }) {
+  const context = new MessengerContext({
+    client: {} as any,
+    event,
+  });
+
+  await expectRouteNotMatchContext({
+    route,
+    context,
+  });
+}
+
+class TestContext extends Context<any, any> {
+  get platform() {
+    return 'test';
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-empty-function
+  sendText() {}
+}
+
+describe('#messenger', () => {
+  it('should call action when it receives a messenger event', async () => {
+    await expectRouteMatchLineEvent({
+      route: messenger(Action),
+      event: messengerEventTextMessage,
+    });
+  });
+
+  it('should not call action when it receives a non-messenger event', async () => {
+    await expectRouteNotMatchContext({
+      route: messenger(Action),
+      context: new TestContext({
+        client: {} as any,
+        event: {},
+      }),
+    });
+  });
+
+  describe('#messenger.message', () => {
+    it('should call action when it receives a messenger message event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.message(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+
+    it('should not call action when it receives a non-message event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.message(Action),
+        event: messengerEventPostback,
+      });
+    });
+  });
+
+  describe('#messenger.accountLinking', () => {
+    it('should call action when it receives a messenger accountLinking event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.accountLinking(Action),
+        event: messengerEventAccountLinkingLinked,
+      });
+    });
+
+    it('should not call action when it receives a non-accountLinking event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.accountLinking(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+
+    describe('#messenger.accountLinking.linked', () => {
+      it('should call action when it receives a messenger accountLinking.linked event', async () => {
+        await expectRouteMatchLineEvent({
+          route: messenger.accountLinking.linked(Action),
+          event: messengerEventAccountLinkingLinked,
+        });
+      });
+
+      it('should not call action when it receives a non-accountLinking.linked event', async () => {
+        await expectRouteNotMatchLineEvent({
+          route: messenger.accountLinking.linked(Action),
+          event: messengerEventAccountLinkingUnlinked,
+        });
+      });
+    });
+
+    describe('#messenger.accountLinking.unlinked', () => {
+      it('should call action when it receives a messenger accountLinking.unlinked event', async () => {
+        await expectRouteMatchLineEvent({
+          route: messenger.accountLinking.unlinked(Action),
+          event: messengerEventAccountLinkingUnlinked,
+        });
+      });
+
+      it('should not call action when it receives a non-accountLinking.unlinked event', async () => {
+        await expectRouteNotMatchLineEvent({
+          route: messenger.accountLinking.unlinked(Action),
+          event: messengerEventAccountLinkingLinked,
+        });
+      });
+    });
+  });
+
+  describe('#messenger.checkoutUpdate', () => {
+    it('should call action when it receives a messenger checkoutUpdate event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.checkoutUpdate(Action),
+        event: messengerEventCheckoutUpdate,
+      });
+    });
+
+    it('should not call action when it receives a non-checkoutUpdate event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.checkoutUpdate(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.delivery', () => {
+    it('should call action when it receives a messenger delivery event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.delivery(Action),
+        event: messengerEventDelivery,
+      });
+    });
+
+    it('should not call action when it receives a non-delivery event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.delivery(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.echo', () => {
+    it('should call action when it receives a messenger echo event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.echo(Action),
+        event: messengerEventEcho,
+      });
+    });
+
+    it('should not call action when it receives a non-echo event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.echo(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.gamePlay', () => {
+    it('should call action when it receives a messenger gamePlay event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.gamePlay(Action),
+        event: messengerEventGamePlay,
+      });
+    });
+
+    it('should not call action when it receives a non-gamePlay event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.gamePlay(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.passThreadControl', () => {
+    it('should call action when it receives a messenger passThreadControl event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.passThreadControl(Action),
+        event: messengerEventPassThreadControl,
+      });
+    });
+
+    it('should not call action when it receives a non-passThreadControl event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.passThreadControl(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.takeThreadControl', () => {
+    it('should call action when it receives a messenger takeThreadControl event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.takeThreadControl(Action),
+        event: messengerEventTakeThreadControl,
+      });
+    });
+
+    it('should not call action when it receives a non-takeThreadControl event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.takeThreadControl(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.requestThreadControl', () => {
+    it('should call action when it receives a messenger requestThreadControl event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.requestThreadControl(Action),
+        event: messengerEventRequestThreadControl,
+      });
+    });
+
+    it('should not call action when it receives a non-requestThreadControl event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.requestThreadControl(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.appRoles', () => {
+    it('should call action when it receives a messenger appRoles event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.appRoles(Action),
+        event: messengerEventAppRoles,
+      });
+    });
+
+    it('should not call action when it receives a non-appRoles event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.appRoles(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.optin', () => {
+    it('should call action when it receives a messenger optin event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.optin(Action),
+        event: messengerEventOptin,
+      });
+    });
+
+    it('should not call action when it receives a non-optin event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.optin(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.payment', () => {
+    it('should call action when it receives a messenger payment event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.payment(Action),
+        event: messengerEventPayment,
+      });
+    });
+
+    it('should not call action when it receives a non-payment event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.payment(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.policyEnforcement', () => {
+    it('should call action when it receives a messenger policyEnforcement event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.policyEnforcement(Action),
+        event: messengerEventPolicyEnforcement,
+      });
+    });
+
+    it('should not call action when it receives a non-policyEnforcement event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.policyEnforcement(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.postback', () => {
+    it('should call action when it receives a messenger postback event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.postback(Action),
+        event: messengerEventPostback,
+      });
+    });
+
+    it('should not call action when it receives a non-postback event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.postback(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.preCheckout', () => {
+    it('should call action when it receives a messenger preCheckout event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.preCheckout(Action),
+        event: messengerEventPreCheckout,
+      });
+    });
+
+    it('should not call action when it receives a non-preCheckout event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.preCheckout(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.read', () => {
+    it('should call action when it receives a messenger read event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.read(Action),
+        event: messengerEventRead,
+      });
+    });
+
+    it('should not call action when it receives a non-read event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.read(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.referral', () => {
+    it('should call action when it receives a messenger referral event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.referral(Action),
+        event: messengerEventReferral,
+      });
+    });
+
+    it('should not call action when it receives a non-referral event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.referral(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+
+  describe('#messenger.standby', () => {
+    it('should call action when it receives a messenger standby event', async () => {
+      await expectRouteMatchLineEvent({
+        route: messenger.standby(Action),
+        event: messengerEventStandby,
+      });
+    });
+
+    it('should not call action when it receives a non-standby event', async () => {
+      await expectRouteNotMatchLineEvent({
+        route: messenger.standby(Action),
+        event: messengerEventTextMessage,
+      });
+    });
+  });
+});
diff --git a/packages/bottender/src/router/__tests__/router.spec.ts b/packages/bottender/src/router/__tests__/router.spec.ts
index 9d37f1b5..3b4525cc 100644
--- a/packages/bottender/src/router/__tests__/router.spec.ts
+++ b/packages/bottender/src/router/__tests__/router.spec.ts
@@ -1,6 +1,6 @@
 import { run } from '../../bot/Bot';
 
-import router, { line, payload, platform, route, text } from '..';
+import router, { line, messenger, payload, platform, route, text } from '..';
 
 function textContext(message = '') {
   return {
@@ -345,3 +345,7 @@ describe('#platform', () => {
 it('#line should be exported', () => {
   expect(line).toBeDefined();
 });
+
+it('#messenger should be exported', () => {
+  expect(messenger).toBeDefined();
+});