Repository: Yoctol/bottender
Pull request #448 (2019-09-24 17:15:43)

fix(ngrok): find tunnel by proto
Recently, I found that the tunnels are ordered randomly in this version, so we should find the right tunnel by the protocol.

codecov-io: # [Codecov](https://codecov.io/gh/Yoctol/bottender/pull/448?src=pr&el=h1) Report
> Merging [#448](https://codecov.io/gh/Yoctol/bottender/pull/448?src=pr&el=desc) into [develop](https://codecov.io/gh/Yoctol/bottender/commit/ede9684ce02703f4b54c934ecd2d9bb5a5b032fd?src=pr&el=desc) will **decrease** coverage by `0.39%`.
> The diff coverage is `84.61%`.

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

```diff
@@            Coverage Diff             @@
##           develop     #448     +/-   ##
==========================================
- Coverage     85.2%   84.81%   -0.4%     
==========================================
  Files           97      103      +6     
  Lines         3528     3766    +238     
  Branches       841      869     +28     
==========================================
+ Hits          3006     3194    +188     
- Misses         489      539     +50     
  Partials        33       33
```


| [Impacted Files](https://codecov.io/gh/Yoctol/bottender/pull/448?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [...s/bottender/src/cli/providers/messenger/webhook.ts](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jbGkvcHJvdmlkZXJzL21lc3Nlbmdlci93ZWJob29rLnRz) | `78.87% <100%> (ø)` | :arrow_up: |
| [...kages/bottender/src/cli/providers/viber/webhook.ts](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jbGkvcHJvdmlkZXJzL3ZpYmVyL3dlYmhvb2sudHM=) | `73.68% <100%> (ø)` | :arrow_up: |
| [...es/bottender/src/cli/providers/telegram/webhook.ts](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jbGkvcHJvdmlkZXJzL3RlbGVncmFtL3dlYmhvb2sudHM=) | `74.35% <100%> (ø)` | :arrow_up: |
| [...es/bottender/src/cli/shared/getWebhookFromNgrok.ts](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jbGkvc2hhcmVkL2dldFdlYmhvb2tGcm9tTmdyb2sudHM=) | `83.33% <80%> (-16.67%)` | :arrow_down: |
| [...ages/bottender/src/cli/providers/messenger/help.ts](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jbGkvcHJvdmlkZXJzL21lc3Nlbmdlci9oZWxwLnRz) | `100% <0%> (ø)` | :arrow_up: |
| [...ges/bottender/src/cli/providers/messenger/index.ts](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jbGkvcHJvdmlkZXJzL21lc3Nlbmdlci9pbmRleC50cw==) | `100% <0%> (ø)` | :arrow_up: |
| [...ttender/src/cli/providers/messenger/get-started.ts](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jbGkvcHJvdmlkZXJzL21lc3Nlbmdlci9nZXQtc3RhcnRlZC50cw==) | `80.39% <0%> (ø)` | |
| [packages/bottender/src/cli/shared/path.ts](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jbGkvc2hhcmVkL3BhdGgudHM=) | `100% <0%> (ø)` | |
| [...der/src/cli/providers/messenger/persistent-menu.ts](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jbGkvcHJvdmlkZXJzL21lc3Nlbmdlci9wZXJzaXN0ZW50LW1lbnUudHM=) | `82.75% <0%> (ø)` | |
| [packages/bottender/src/cli/shared/loadModule.ts](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jbGkvc2hhcmVkL2xvYWRNb2R1bGUudHM=) | `0% <0%> (ø)` | |
| ... and [2 more](https://codecov.io/gh/Yoctol/bottender/pull/448/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/Yoctol/bottender/pull/448?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/448?src=pr&el=footer). Last update [ede9684...f042174](https://codecov.io/gh/Yoctol/bottender/pull/448?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/packages/bottender-handlers/src/middleware.ts b/packages/bottender-handlers/src/middleware.ts
index 4a1f2862..86b9b02d 100644
--- a/packages/bottender-handlers/src/middleware.ts
+++ b/packages/bottender-handlers/src/middleware.ts
@@ -4,7 +4,7 @@ import { Builder } from './Handler';
 
 type Middleware = (context?: any, next?: Middleware) => {};
 
-const middleware = (m: (Middleware | Builder)[]) =>
+const middleware = (m: Array<Middleware | Builder>) =>
   compose(m.map(item => ('build' in item ? item.build() : item)));
 
 export default middleware;
diff --git a/packages/bottender/src/bot/Connector.ts b/packages/bottender/src/bot/Connector.ts
index fd1d7f44..f1be6362 100644
--- a/packages/bottender/src/bot/Connector.ts
+++ b/packages/bottender/src/bot/Connector.ts
@@ -10,7 +10,7 @@ export interface Connector<B, C> {
     requestContext?: Record<string, any> | null
   ): string | null;
   updateSession(session: Session, body: B): Promise<void>;
-  mapRequestToEvents(body: B): any[];
+  mapRequestToEvents(body: B): Array<any>;
   createContext(params: {
     event: any;
     session?: Session | null;
diff --git a/packages/bottender/src/bot/LineConnector.ts b/packages/bottender/src/bot/LineConnector.ts
index 21206384..5ad6aacb 100644
--- a/packages/bottender/src/bot/LineConnector.ts
+++ b/packages/bottender/src/bot/LineConnector.ts
@@ -12,7 +12,7 @@ import { Connector } from './Connector';
 
 export type LineRequestBody = {
   destination: string;
-  events: LineRawEvent[];
+  events: Array<LineRawEvent>;
 };
 
 type CommonConstructorOptions = {
@@ -252,7 +252,7 @@ export default class LineConnector
     });
   }
 
-  mapRequestToEvents(body: LineRequestBody): LineEvent[] {
+  mapRequestToEvents(body: LineRequestBody): Array<LineEvent> {
     const { destination } = body;
 
     return body.events
diff --git a/packages/bottender/src/bot/MessengerConnector.ts b/packages/bottender/src/bot/MessengerConnector.ts
index db3c2e7f..79e1170d 100644
--- a/packages/bottender/src/bot/MessengerConnector.ts
+++ b/packages/bottender/src/bot/MessengerConnector.ts
@@ -316,7 +316,7 @@ export default class MessengerConnector
     });
   }
 
-  mapRequestToEvents(body: MessengerRequestBody): MessengerEvent[] {
+  mapRequestToEvents(body: MessengerRequestBody): Array<MessengerEvent> {
     const rawEvents = this._getRawEventsFromRequest(body);
     const isStandby = this._isStandby(body);
 
diff --git a/packages/bottender/src/bot/SlackConnector.ts b/packages/bottender/src/bot/SlackConnector.ts
index 88b4b50c..8650f0fc 100644
--- a/packages/bottender/src/bot/SlackConnector.ts
+++ b/packages/bottender/src/bot/SlackConnector.ts
@@ -278,7 +278,7 @@ export default class SlackConnector
     }
   }
 
-  mapRequestToEvents(body: SlackRequestBody): SlackEvent[] {
+  mapRequestToEvents(body: SlackRequestBody): Array<SlackEvent> {
     const rawEvent = this._getRawEventFromRequest(body);
 
     if (this._isBotEventRequest(body)) {
diff --git a/packages/bottender/src/bot/TelegramBot.ts b/packages/bottender/src/bot/TelegramBot.ts
index 8006e093..ed11f6b9 100644
--- a/packages/bottender/src/bot/TelegramBot.ts
+++ b/packages/bottender/src/bot/TelegramBot.ts
@@ -9,7 +9,7 @@ type PollingOptions = {
   offset?: number;
   limit?: number;
   timeout?: number;
-  allowed_updates?: string[];
+  allowed_updates?: Array<string>;
 };
 
 export default class TelegramBot extends Bot<
diff --git a/packages/bottender/src/bot/TelegramConnector.ts b/packages/bottender/src/bot/TelegramConnector.ts
index ac537d84..450baca8 100644
--- a/packages/bottender/src/bot/TelegramConnector.ts
+++ b/packages/bottender/src/bot/TelegramConnector.ts
@@ -159,7 +159,7 @@ export default class TelegramConnector
     });
   }
 
-  mapRequestToEvents(body: TelegramRequestBody): TelegramEvent[] {
+  mapRequestToEvents(body: TelegramRequestBody): Array<TelegramEvent> {
     const rawEvent = this._getRawEventFromRequest(body);
     return [new TelegramEvent(rawEvent)];
   }
diff --git a/packages/bottender/src/bot/ViberConnector.ts b/packages/bottender/src/bot/ViberConnector.ts
index cb2f12f8..b1fe52ee 100644
--- a/packages/bottender/src/bot/ViberConnector.ts
+++ b/packages/bottender/src/bot/ViberConnector.ts
@@ -120,7 +120,7 @@ export default class ViberConnector
     });
   }
 
-  mapRequestToEvents(body: ViberRequestBody): ViberEvent[] {
+  mapRequestToEvents(body: ViberRequestBody): Array<ViberEvent> {
     const rawEvent = this._getRawEventFromRequest(body);
     return [new ViberEvent(rawEvent)];
   }
diff --git a/packages/bottender/src/cli/providers/messenger/get-started.ts b/packages/bottender/src/cli/providers/messenger/get-started.ts
new file mode 100644
index 00000000..f8910c98
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/get-started.ts
@@ -0,0 +1,106 @@
+/* eslint-disable consistent-return */
+import chalk from 'chalk';
+import invariant from 'invariant';
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../shared/getConfig';
+import { CliContext } from '../..';
+import { bold, error, print } from '../../shared/log';
+
+const help = (): void => {
+  console.log(`
+    bottender messenger get-started <command> [option]
+
+    ${chalk.dim('Commands:')}
+
+      get               Get get_started setting.
+      del, delete       Delete get_started setting.
+
+    ${chalk.dim('Examples:')}
+
+    ${chalk.dim('-')} Get get_started setting
+
+      ${chalk.cyan('$ bottender messenger get-started get')}
+
+    ${chalk.dim('-')} Delete get_started setting with specific access token
+
+      ${chalk.cyan('$ bottender messenger get-started delete')}
+  `);
+};
+
+export async function getGetStarted(_: CliContext): Promise<void> {
+  try {
+    const config = getConfig('messenger');
+
+    invariant(config.accessToken, 'accessToken is not found in config file');
+
+    const accessToken = config.accessToken;
+
+    const client = MessengerClient.connect(accessToken);
+
+    const getStarted = await client.getGetStarted();
+
+    if (getStarted && getStarted.payload) {
+      print(`Get started payload is: ${bold(getStarted.payload)}`);
+    } else {
+      error(`Failed to find ${bold('get_started')} setting`);
+    }
+    return;
+  } catch (err) {
+    error(`Failed to get ${bold('get_started')} setting`);
+    if (err.response) {
+      error(`status: ${bold(err.response.status)}`);
+      if (err.response.data) {
+        error(`data: ${bold(JSON.stringify(err.response.data, null, 2))}`);
+      }
+    } else {
+      error(err.message);
+    }
+    return process.exit(1);
+  }
+}
+
+export async function deleteGetStarted(_: CliContext): Promise<void> {
+  try {
+    const config = getConfig('messenger');
+
+    invariant(config.accessToken, 'accessToken is not found in config file');
+
+    const accessToken = config.accessToken;
+
+    const client = MessengerClient.connect(accessToken);
+
+    await client.deleteGetStarted();
+
+    print(`Successfully delete ${bold('get_started')} setting`);
+    return;
+  } catch (err) {
+    error(`Failed to delete ${bold('get_started')} setting`);
+    if (err.response) {
+      error(`status: ${bold(err.response.status)}`);
+      if (err.response.data) {
+        error(`data: ${bold(JSON.stringify(err.response.data, null, 2))}`);
+      }
+    } else {
+      error(err.message);
+    }
+    return process.exit(1);
+  }
+}
+
+export default async function main(ctx: CliContext): Promise<void> {
+  const subcommand = ctx.argv._[2];
+
+  switch (subcommand) {
+    case 'get':
+      await getGetStarted(ctx);
+      break;
+    case 'delete':
+    case 'del':
+      await deleteGetStarted(ctx);
+      break;
+    default:
+      error(`Please specify a valid subcommand: get, delete`);
+      help();
+  }
+}
diff --git a/packages/bottender/src/cli/providers/messenger/greeting.ts b/packages/bottender/src/cli/providers/messenger/greeting.ts
new file mode 100644
index 00000000..db0ddaf4
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/greeting.ts
@@ -0,0 +1,106 @@
+/* eslint-disable consistent-return */
+import chalk from 'chalk';
+import invariant from 'invariant';
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../shared/getConfig';
+import { CliContext } from '../..';
+import { bold, error, print } from '../../shared/log';
+
+const help = (): void => {
+  console.log(`
+    bottender messenger greeting <command> [option]
+
+    ${chalk.dim('Commands:')}
+
+      get               Get greeting setting.
+      del, delete       Delete greeting setting.
+
+    ${chalk.dim('Examples:')}
+
+    ${chalk.dim('-')} Get greeting setting
+
+      ${chalk.cyan('$ bottender messenger greeting get')}
+
+    ${chalk.dim('-')} Delete greeting setting with specific access token
+
+      ${chalk.cyan('$ bottender messenger greeting delete')}
+  `);
+};
+
+export async function getGreeting(_: CliContext): Promise<void> {
+  try {
+    const config = getConfig('messenger');
+
+    invariant(config.accessToken, 'accessToken is not found in config file');
+
+    const accessToken = config.accessToken;
+
+    const client = MessengerClient.connect(accessToken);
+
+    const greeting = await client.getGreeting();
+
+    if (greeting && greeting[0] && greeting[0].text) {
+      print(`The greeting is: ${bold(greeting[0].text)}`);
+    } else {
+      error(`Failed to find ${bold('greeting')} setting`);
+    }
+    return;
+  } catch (err) {
+    error(`Failed to get ${bold('greeting')} setting`);
+    if (err.response) {
+      error(`status: ${bold(err.response.status)}`);
+      if (err.response.data) {
+        error(`data: ${bold(JSON.stringify(err.response.data, null, 2))}`);
+      }
+    } else {
+      error(err.message);
+    }
+    return process.exit(1);
+  }
+}
+
+export async function deleteGreeting(_: CliContext): Promise<void> {
+  try {
+    const config = getConfig('messenger');
+
+    invariant(config.accessToken, 'accessToken is not found in config file');
+
+    const accessToken = config.accessToken;
+
+    const client = MessengerClient.connect(accessToken);
+
+    await client.deleteGreeting();
+
+    print(`Successfully delete ${bold('greeting')} setting`);
+    return;
+  } catch (err) {
+    error(`Failed to delete ${bold('greeting')} setting`);
+    if (err.response) {
+      error(`status: ${bold(err.response.status)}`);
+      if (err.response.data) {
+        error(`data: ${bold(JSON.stringify(err.response.data, null, 2))}`);
+      }
+    } else {
+      error(err.message);
+    }
+    return process.exit(1);
+  }
+}
+
+export default async function main(ctx: CliContext): Promise<void> {
+  const subcommand = ctx.argv._[2];
+
+  switch (subcommand) {
+    case 'get':
+      await getGreeting(ctx);
+      break;
+    case 'delete':
+    case 'del':
+      await deleteGreeting(ctx);
+      break;
+    default:
+      error(`Please specify a valid subcommand: get, delete`);
+      help();
+  }
+}
diff --git a/packages/bottender/src/cli/providers/messenger/help.ts b/packages/bottender/src/cli/providers/messenger/help.ts
index 6bfa95bf..b2ef511f 100644
--- a/packages/bottender/src/cli/providers/messenger/help.ts
+++ b/packages/bottender/src/cli/providers/messenger/help.ts
@@ -6,9 +6,15 @@ const help = (): void => {
 
     ${chalk.dim('Commands:')}
 
+      get-started             <action>    Manage get-started
+      greeting                <action>    Manage greeting text
+      menu, persistent-menu   <action>    Manage persistent-menu
       profile                 <action>    Manage Messenger profile
       webhook                 <action>    Manage webhook
+      domains,
+      whitelisted-domains     <action>    Manage whitelisted-domains
       persona                 <action>    Manage persona
+      attachment              <action>    Manage attachment
 
     ${chalk.dim('Actions:')}
 
@@ -21,6 +27,14 @@ const help = (): void => {
     ${chalk.dim('-')} Set the messenger profile
 
       ${chalk.cyan('$ bottender messenger profile set')}
+
+    ${chalk.dim('-')} Delete the persistent-menu
+
+      ${chalk.cyan('$ bottender messenger persistent-menu del')}
+
+    ${chalk.dim('-')} Show attachment command usage
+
+      ${chalk.cyan('$ bottender messenger attachment help')}
   `);
 };
 
diff --git a/packages/bottender/src/cli/providers/messenger/index.ts b/packages/bottender/src/cli/providers/messenger/index.ts
index 54f26c66..0eb917af 100644
--- a/packages/bottender/src/cli/providers/messenger/index.ts
+++ b/packages/bottender/src/cli/providers/messenger/index.ts
@@ -1,6 +1,24 @@
 export default {
   title: 'Bottender',
-  subcommands: new Set(['help', 'persona', 'profile', 'webhook']),
+  subcommands: new Set([
+    'help',
+    'get-started',
+    'greeting',
+    'persistent-menu',
+    'persona',
+    'profile',
+    'webhook',
+    'whitelisted-domains',
+  ]),
+  get getStarted() {
+    return require('./get-started').default;
+  },
+  get greeting() {
+    return require('./greeting').default;
+  },
+  get persistentMenu() {
+    return require('./persistent-menu').default;
+  },
   get persona() {
     return require('./persona').default;
   },
@@ -10,7 +28,18 @@ export default {
   get webhook() {
     return require('./webhook').default;
   },
+  get whitelistedDomains() {
+    return require('./whitelisted-domains').default;
+  },
   get help() {
     return require('./help').default;
   },
+
+  // alias
+  get menu() {
+    return this.persistentMenu;
+  },
+  get domains() {
+    return this.whitelistedDomains;
+  },
 };
diff --git a/packages/bottender/src/cli/providers/messenger/persistent-menu.ts b/packages/bottender/src/cli/providers/messenger/persistent-menu.ts
new file mode 100644
index 00000000..ab4eeb32
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/persistent-menu.ts
@@ -0,0 +1,117 @@
+/* eslint-disable consistent-return */
+import Table from 'cli-table3';
+import chalk from 'chalk';
+import invariant from 'invariant';
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../shared/getConfig';
+import { CliContext } from '../..';
+import { bold, error, print } from '../../shared/log';
+
+const help = (): void => {
+  console.log(`
+    bottender messenger persistent-menu <command> [option]
+
+    ${chalk.dim('Commands:')}
+
+      get               Get persistent-menu setting.
+      del, delete       Delete persistent-menu setting.
+
+    ${chalk.dim('Examples:')}
+
+    ${chalk.dim('-')} Get persistent-menu setting
+
+      ${chalk.cyan('$ bottender messenger persistent-menu get')}
+
+    ${chalk.dim('-')} Delete persistent-menu setting with specific access token
+
+      ${chalk.cyan('$ bottender messenger persistent-menu delete')}
+  `);
+};
+
+export async function getPersistentMenu(_: CliContext): Promise<void> {
+  try {
+    const config = getConfig('messenger');
+
+    invariant(config.accessToken, 'accessToken is not found in config file');
+
+    const accessToken = config.accessToken;
+
+    const client = MessengerClient.connect(accessToken);
+
+    const persistentMenu = await client.getPersistentMenu();
+
+    if (persistentMenu && persistentMenu[0]) {
+      const menu = persistentMenu[0];
+      print(`input disabled: ${menu.composer_input_disabled}`);
+      print('actions:');
+      const table = new Table({
+        head: ['type', 'title', 'payload'],
+        colWidths: [30, 30, 30],
+      });
+      menu.call_to_actions.forEach(item => {
+        table.push([item.type, item.title, item.payload] as any);
+      });
+      console.log(table.toString()); // eslint-disable-line no-console
+    } else {
+      error(`Failed to find ${bold('persistent_menu')} setting`);
+    }
+    return;
+  } catch (err) {
+    error(`Faile to get ${bold('persistent_menu')} setting`);
+    if (err.response) {
+      error(`status: ${bold(err.response.status)}`);
+      if (err.response.data) {
+        error(`data: ${bold(JSON.stringify(err.response.data, null, 2))}`);
+      }
+    } else {
+      error(err.message);
+    }
+    return process.exit(1);
+  }
+}
+
+export async function deletePersistentMenu(_: CliContext): Promise<void> {
+  try {
+    const config = getConfig('messenger');
+
+    invariant(config.accessToken, 'accessToken is not found in config file');
+
+    const accessToken = config.accessToken;
+
+    const client = MessengerClient.connect(accessToken);
+
+    await client.deletePersistentMenu();
+
+    print(`Successfully delete ${bold('persistent_menu')} setting`);
+    return;
+  } catch (err) {
+    error(`Failed to delete ${bold('persistent_menu')} setting`);
+    if (err.response) {
+      error(`status: ${bold(err.response.status)}`);
+      if (err.response.data) {
+        error(`data: ${bold(JSON.stringify(err.response.data, null, 2))}`);
+      }
+    } else {
+      error(err.message);
+    }
+    return process.exit(1);
+  }
+}
+
+export default async function main(ctx: CliContext): Promise<void> {
+  const subcommand = ctx.argv._[2];
+
+  switch (subcommand) {
+    case 'get':
+      await getPersistentMenu(ctx);
+      break;
+    case 'delete':
+    case 'del':
+      await deletePersistentMenu(ctx);
+      break;
+    default:
+      error(`Please specify a valid subcommand: get, delete`);
+      help();
+  }
+}
diff --git a/packages/bottender/src/cli/providers/messenger/webhook.ts b/packages/bottender/src/cli/providers/messenger/webhook.ts
index dc2941d7..fae867b8 100644
--- a/packages/bottender/src/cli/providers/messenger/webhook.ts
+++ b/packages/bottender/src/cli/providers/messenger/webhook.ts
@@ -44,7 +44,7 @@ export async function setWebhook(ctx: CliContext): Promise<void> {
   });
 
   let webhook = argv['--webhook'];
-  const ngrokPort = argv['--ngrok-port'] || '4040';
+  const ngrokPort = argv['--ngrok-port'];
 
   try {
     const config = getConfig('messenger');
diff --git a/packages/bottender/src/cli/providers/messenger/whitelisted-domains.ts b/packages/bottender/src/cli/providers/messenger/whitelisted-domains.ts
new file mode 100644
index 00000000..d1b44abe
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/whitelisted-domains.ts
@@ -0,0 +1,107 @@
+/* eslint-disable consistent-return */
+import chalk from 'chalk';
+import invariant from 'invariant';
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../shared/getConfig';
+import { CliContext } from '../..';
+import { bold, error, print } from '../../shared/log';
+
+const help = (): void => {
+  console.log(`
+    bottender messenger whitelisted-domains <command> [option]
+
+    ${chalk.dim('Commands:')}
+
+      get               Get whitelisted-domains setting.
+      del, delete       Delete whitelisted-domains setting.
+
+    ${chalk.dim('Examples:')}
+
+    ${chalk.dim('-')} Get whitelisted-domains setting
+
+      ${chalk.cyan('$ bottender messenger whitelisted-domains get')}
+
+    ${chalk.dim('-')} Delete persistent-menu setting with specific access token
+
+      ${chalk.cyan('$ bottender messenger persistent-menu delete')}
+  `);
+};
+
+export async function getWhitelistedDomains(_: CliContext): Promise<void> {
+  try {
+    const config = getConfig('messenger');
+
+    invariant(config.accessToken, 'accessToken is not found in config file');
+
+    const accessToken = config.accessToken;
+
+    const client = MessengerClient.connect(accessToken);
+
+    const whitelistedDomains = await client.getWhitelistedDomains();
+    if (whitelistedDomains) {
+      for (let i = 0; i < whitelistedDomains.length; i++) {
+        print(`The whitelisted domains is: ${bold(whitelistedDomains[i])}`);
+      }
+    } else {
+      error(`Failed to find ${bold('whitelisted-domains')} setting`);
+    }
+    return;
+  } catch (err) {
+    error(`Failed to get ${bold('whitelisted-domains')} setting`);
+    if (err.response) {
+      error(`status: ${bold(err.response.status)}`);
+      if (err.response.data) {
+        error(`data: ${bold(JSON.stringify(err.response.data, null, 2))}`);
+      }
+    } else {
+      error(err.message);
+    }
+    return process.exit(1);
+  }
+}
+
+export async function deleteWhitelistedDomains(_: CliContext): Promise<void> {
+  try {
+    const config = getConfig('messenger');
+
+    invariant(config.accessToken, 'accessToken is not found in config file');
+
+    const accessToken = config.accessToken;
+
+    const client = MessengerClient.connect(accessToken);
+
+    await client.deleteWhitelistedDomains();
+
+    print(`Successfully delete ${bold('whitelisted-domains')} setting`);
+    return;
+  } catch (err) {
+    error(`Failed to delete ${bold('whitelisted-domains')} setting`);
+    if (err.response) {
+      error(`status: ${bold(err.response.status)}`);
+      if (err.response.data) {
+        error(`data: ${bold(JSON.stringify(err.response.data, null, 2))}`);
+      }
+    } else {
+      error(err.message);
+    }
+    return process.exit(1);
+  }
+}
+
+export default async function main(ctx: CliContext): Promise<void> {
+  const subcommand = ctx.argv._[2];
+
+  switch (subcommand) {
+    case 'get':
+      await getWhitelistedDomains(ctx);
+      break;
+    case 'delete':
+    case 'del':
+      await deleteWhitelistedDomains(ctx);
+      break;
+    default:
+      error(`Please specify a valid subcommand: get, delete`);
+      help();
+  }
+}
diff --git a/packages/bottender/src/cli/providers/telegram/webhook.ts b/packages/bottender/src/cli/providers/telegram/webhook.ts
index 2f9eba21..b8be5038 100644
--- a/packages/bottender/src/cli/providers/telegram/webhook.ts
+++ b/packages/bottender/src/cli/providers/telegram/webhook.ts
@@ -44,7 +44,7 @@ export async function setWebhook(ctx: CliContext): Promise<void> {
     '--ngrok-port': String,
   });
 
-  const ngrokPort = argv['--ngrok-port'] || '4040';
+  const ngrokPort = argv['--ngrok-port'];
   let webhook = argv['--webhook'];
 
   try {
diff --git a/packages/bottender/src/cli/providers/viber/webhook.ts b/packages/bottender/src/cli/providers/viber/webhook.ts
index e354cce0..f3d8eccf 100644
--- a/packages/bottender/src/cli/providers/viber/webhook.ts
+++ b/packages/bottender/src/cli/providers/viber/webhook.ts
@@ -24,7 +24,7 @@ export async function setWebhook(ctx: CliContext): Promise<void> {
   });
 
   let webhook = argv['--webhook'];
-  const ngrokPort = argv['--ngrok-port'] || '4040';
+  const ngrokPort = argv['--ngrok-port'];
 
   try {
     const config: ViberConfig = getConfig('viber');
diff --git a/packages/bottender/src/cli/shared/getWebhookFromNgrok.ts b/packages/bottender/src/cli/shared/getWebhookFromNgrok.ts
index b152f93d..79b7aec0 100644
--- a/packages/bottender/src/cli/shared/getWebhookFromNgrok.ts
+++ b/packages/bottender/src/cli/shared/getWebhookFromNgrok.ts
@@ -1,12 +1,60 @@
 import axios from 'axios';
-import get from 'lodash/get';
 
-export default async function getWebhookFromNgrok(ngrokPort: string) {
+type Tunnel = {
+  name: string;
+  uri: string;
+  public_url: string;
+  proto: string;
+  config: {
+    addr: string;
+    inspect: boolean;
+  };
+  metrics: {
+    conns: {
+      count: number;
+      gauge: number;
+      rate1: number;
+      rate5: number;
+      rate15: number;
+      p50: number;
+      p90: number;
+      p95: number;
+      p99: number;
+    };
+    http: {
+      count: number;
+      rate1: number;
+      rate5: number;
+      rate15: number;
+      p50: number;
+      p90: number;
+      p95: number;
+      p99: number;
+    };
+  };
+};
+
+export default async function getWebhookFromNgrok(
+  ngrokPort = '4040'
+): Promise<string | never> {
+  // ngork API reference: https://ngrok.com/docs#client-api
   const ngrokAxios = axios.create({
     baseURL: `http://localhost:${ngrokPort}`,
   });
 
-  // TODO: use ngrok Client API? - https://ngrok.com/docs#client-api
-  const res = await ngrokAxios.get('/api/tunnels');
-  return get(res, 'data.tunnels[1].public_url'); // tunnels[1] return `https` protocol
+  const { data } = await ngrokAxios.get<{
+    tunnels: Tunnel[];
+  }>('/api/tunnels');
+
+  if (!data) {
+    throw new Error('Failed to get tunnels from ngrok');
+  }
+
+  const httpsTunnel = data.tunnels.find(tunnel => tunnel.proto === 'https');
+
+  if (!httpsTunnel) {
+    throw new Error('Can not find a https tunnel');
+  }
+
+  return httpsTunnel.public_url;
 }
diff --git a/packages/bottender/src/cli/shared/loadModule.ts b/packages/bottender/src/cli/shared/loadModule.ts
new file mode 100644
index 00000000..9c5c47e7
--- /dev/null
+++ b/packages/bottender/src/cli/shared/loadModule.ts
@@ -0,0 +1,16 @@
+import path from 'path';
+
+export default function loadModule(modulePath: string) {
+  let mod;
+  try {
+    mod = require(path.resolve(modulePath)); // eslint-disable-line import/no-dynamic-require
+  } catch (err) {} // eslint-disable-line
+
+  if (!mod) return null;
+
+  if (typeof mod === 'object' && mod.default) {
+    return mod.default;
+  }
+
+  return mod;
+}
diff --git a/packages/bottender/src/cli/shared/path.ts b/packages/bottender/src/cli/shared/path.ts
new file mode 100644
index 00000000..f712cb48
--- /dev/null
+++ b/packages/bottender/src/cli/shared/path.ts
@@ -0,0 +1,11 @@
+import path from 'path';
+
+export function toAbsolutePath(relativeOrAbsolutePath: string): string {
+  if (
+    path.isAbsolute(relativeOrAbsolutePath) ||
+    relativeOrAbsolutePath.startsWith('~')
+  ) {
+    return relativeOrAbsolutePath;
+  }
+  return path.join(process.cwd(), relativeOrAbsolutePath);
+}
diff --git a/packages/bottender/src/context/LineContext.ts b/packages/bottender/src/context/LineContext.ts
index b7b76334..205ed67a 100644
--- a/packages/bottender/src/context/LineContext.ts
+++ b/packages/bottender/src/context/LineContext.ts
@@ -447,7 +447,7 @@ class LineContext extends Context implements PlatformContext {
   }
 }
 
-const types: { name: string; arity: number; aliases?: string[] }[] = [
+const types: Array<{ name: string; arity: number; aliases?: Array<string> }> = [
   { name: '', arity: 3 },
   { name: 'Text', arity: 3 },
   { name: 'Image', arity: 3 },
diff --git a/packages/bottender/src/context/MessengerEvent.ts b/packages/bottender/src/context/MessengerEvent.ts
index 19c1d863..878106e0 100644
--- a/packages/bottender/src/context/MessengerEvent.ts
+++ b/packages/bottender/src/context/MessengerEvent.ts
@@ -48,12 +48,12 @@ export type Message = {
   text?: string;
   sticker_id?: number;
   quick_reply?: QuickReply;
-  attachments?: Attachment[];
-  tags?: Tag[];
+  attachments?: Array<Attachment>;
+  tags?: Array<Tag>;
 };
 
 export type Delivery = {
-  mids: string[];
+  mids: Array<string>;
   watermark: number;
   seq: number;
 };
@@ -155,7 +155,7 @@ export type RequestThreadControl = {
 };
 
 export type BrandedCamera = {
-  content_ids: string[];
+  content_ids: Array<string>;
   event: string;
 };
 
diff --git a/packages/bottender/src/context/SlackEvent.ts b/packages/bottender/src/context/SlackEvent.ts
index bee88882..646796e1 100644
--- a/packages/bottender/src/context/SlackEvent.ts
+++ b/packages/bottender/src/context/SlackEvent.ts
@@ -70,7 +70,7 @@ export type Message = {
 
 export type InteractiveMessageEvent = {
   type: 'interactive_message';
-  actions: {}[];
+  actions: Array<{}>;
   callback_id: string;
   team: {
     id: string;
diff --git a/packages/bottender/src/context/TelegramContext.ts b/packages/bottender/src/context/TelegramContext.ts
index 9546f2c1..441a0ecb 100644
--- a/packages/bottender/src/context/TelegramContext.ts
+++ b/packages/bottender/src/context/TelegramContext.ts
@@ -122,7 +122,7 @@ class TelegramContext extends Context implements PlatformContext {
     return this._client.answerPreCheckoutQuery(preCheckoutQueryId, ok, options);
   }
 
-  async answerInlineQuery(results: Record<string, any>[], options?: {}) {
+  async answerInlineQuery(results: Array<Record<string, any>>, options?: {}) {
     if (!this._event.isInlineQuery) {
       warning(
         false,
diff --git a/packages/bottender/src/context/TelegramEvent.ts b/packages/bottender/src/context/TelegramEvent.ts
index ef9aacde..17ce0b7f 100644
--- a/packages/bottender/src/context/TelegramEvent.ts
+++ b/packages/bottender/src/context/TelegramEvent.ts
@@ -8,11 +8,11 @@ type TelegramUser = {
   language_code?: string;
 };
 
-type Photo = {
+type Photo = Array<{
   file_id: string;
   width: number;
   height: number;
-}[];
+}>;
 
 type Audio = {
   file_id: string;
@@ -71,11 +71,11 @@ type File = {
 type Game = {
   title: string;
   description: string;
-  photo: {
+  photo: Array<{
     file_id: string;
     width: number;
     height: number;
-  }[];
+  }>;
 };
 
 type Message = {
@@ -89,11 +89,11 @@ type Message = {
   };
   date: number;
   text: string;
-  entities: {
+  entities: Array<{
     type: 'bot_command';
     offset: number;
     length: number;
-  }[];
+  }>;
   reply_to_message?: Message;
   photo?: Photo;
   game?: Game;
diff --git a/packages/bottender/src/session/MongoSessionStore.ts b/packages/bottender/src/session/MongoSessionStore.ts
index 31ca88e8..8f1e5051 100644
--- a/packages/bottender/src/session/MongoSessionStore.ts
+++ b/packages/bottender/src/session/MongoSessionStore.ts
@@ -49,7 +49,7 @@ export default class MongoSessionStore implements SessionStore {
     }
   }
 
-  async all(): Promise<Session[]> {
+  async all(): Promise<Array<Session>> {
     return this._sessions.find().toArray();
   }
 


Test patch:
diff --git a/packages/bottender/src/cli/providers/messenger/__tests__/deleteGetStarted.spec.ts b/packages/bottender/src/cli/providers/messenger/__tests__/deleteGetStarted.spec.ts
new file mode 100644
index 00000000..1e4273bf
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/__tests__/deleteGetStarted.spec.ts
@@ -0,0 +1,115 @@
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../../shared/getConfig';
+import { deleteGetStarted } from '../get-started';
+import * as log from '../../../shared/log';
+
+jest.mock('messaging-api-messenger');
+
+jest.mock('../../../shared/log');
+jest.mock('../../../shared/getConfig');
+
+const MOCK_FILE_WITH_PLATFORM = {
+  channels: {
+    messenger: {
+      accessToken: '__FAKE_TOKEN__',
+    },
+  },
+};
+
+let _client;
+
+beforeEach(() => {
+  _client = {
+    deleteGetStarted: jest.fn(),
+  };
+  MessengerClient.connect = jest.fn(() => _client);
+  log.error = jest.fn();
+  log.print = jest.fn();
+  getConfig.mockReturnValue(MOCK_FILE_WITH_PLATFORM.channels.messenger);
+});
+
+it('be defined', () => {
+  expect(deleteGetStarted).toBeDefined();
+});
+
+describe('resolved', () => {
+  it('call deleteGetStarted', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.deleteGetStarted.mockResolvedValue();
+
+    await deleteGetStarted(ctx);
+
+    expect(_client.deleteGetStarted).toBeCalled();
+  });
+});
+
+describe('reject', () => {
+  it('handle error thrown with only status', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+      },
+    };
+    _client.deleteGetStarted.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await deleteGetStarted(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by messenger', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+        data: {
+          error: {
+            message: '(#100) ...',
+            type: 'OAuthException',
+            code: 100,
+            error_subcode: 2018145,
+            fbtrace_id: 'HXd3kIOXLsK',
+          },
+        },
+      },
+    };
+    _client.deleteGetStarted.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await deleteGetStarted(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(log.error.mock.calls[2][0]).not.toMatch(/\[object Object\]/);
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by ourselves', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      message: 'something wrong happened',
+    };
+    _client.deleteGetStarted.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await deleteGetStarted(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+});
diff --git a/packages/bottender/src/cli/providers/messenger/__tests__/deleteGreeting.spec.ts b/packages/bottender/src/cli/providers/messenger/__tests__/deleteGreeting.spec.ts
new file mode 100644
index 00000000..adea3056
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/__tests__/deleteGreeting.spec.ts
@@ -0,0 +1,115 @@
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../../shared/getConfig';
+import { deleteGreeting } from '../greeting';
+import * as log from '../../../shared/log';
+
+jest.mock('messaging-api-messenger');
+
+jest.mock('../../../shared/log');
+jest.mock('../../../shared/getConfig');
+
+const MOCK_FILE_WITH_PLATFORM = {
+  channels: {
+    messenger: {
+      accessToken: '__FAKE_TOKEN__',
+    },
+  },
+};
+
+let _client;
+
+beforeEach(() => {
+  _client = {
+    deleteGreeting: jest.fn(),
+  };
+  MessengerClient.connect = jest.fn(() => _client);
+  log.error = jest.fn();
+  log.print = jest.fn();
+  getConfig.mockReturnValue(MOCK_FILE_WITH_PLATFORM.channels.messenger);
+});
+
+it('be defined', () => {
+  expect(deleteGreeting).toBeDefined();
+});
+
+describe('resolved', () => {
+  it('call deleteGreeting', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.deleteGreeting.mockResolvedValue();
+
+    await deleteGreeting(ctx);
+
+    expect(_client.deleteGreeting).toBeCalled();
+  });
+});
+
+describe('reject', () => {
+  it('handle error thrown with only status', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+      },
+    };
+    _client.deleteGreeting.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await deleteGreeting(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by messenger', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+        data: {
+          error: {
+            message: '(#100) ...',
+            type: 'OAuthException',
+            code: 100,
+            error_subcode: 2018145,
+            fbtrace_id: 'HXd3kIOXLsK',
+          },
+        },
+      },
+    };
+    _client.deleteGreeting.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await deleteGreeting(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(log.error.mock.calls[2][0]).not.toMatch(/\[object Object\]/);
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by ourselves', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      message: 'something wrong happened',
+    };
+    _client.deleteGreeting.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await deleteGreeting(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+});
diff --git a/packages/bottender/src/cli/providers/messenger/__tests__/deletePersistentMenu.spec.ts b/packages/bottender/src/cli/providers/messenger/__tests__/deletePersistentMenu.spec.ts
new file mode 100644
index 00000000..93cfe385
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/__tests__/deletePersistentMenu.spec.ts
@@ -0,0 +1,115 @@
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../../shared/getConfig';
+import { deletePersistentMenu } from '../persistent-menu';
+import * as log from '../../../shared/log';
+
+jest.mock('messaging-api-messenger');
+
+jest.mock('../../../shared/log');
+jest.mock('../../../shared/getConfig');
+
+const MOCK_FILE_WITH_PLATFORM = {
+  channels: {
+    messenger: {
+      accessToken: '__FAKE_TOKEN__',
+    },
+  },
+};
+
+let _client;
+
+beforeEach(() => {
+  _client = {
+    deletePersistentMenu: jest.fn(),
+  };
+  MessengerClient.connect = jest.fn(() => _client);
+  log.error = jest.fn();
+  log.print = jest.fn();
+  getConfig.mockReturnValue(MOCK_FILE_WITH_PLATFORM.channels.messenger);
+});
+
+it('be defined', () => {
+  expect(deletePersistentMenu).toBeDefined();
+});
+
+describe('resolved', () => {
+  it('call deletePersistentMenu', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.deletePersistentMenu.mockResolvedValue();
+
+    await deletePersistentMenu(ctx);
+
+    expect(_client.deletePersistentMenu).toBeCalled();
+  });
+});
+
+describe('reject', () => {
+  it('handle error thrown with only status', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+      },
+    };
+    _client.deletePersistentMenu.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await deletePersistentMenu(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by messenger', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+        data: {
+          error: {
+            message: '(#100) ...',
+            type: 'OAuthException',
+            code: 100,
+            error_subcode: 2018145,
+            fbtrace_id: 'HXd3kIOXLsK',
+          },
+        },
+      },
+    };
+    _client.deletePersistentMenu.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await deletePersistentMenu(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(log.error.mock.calls[2][0]).not.toMatch(/\[object Object\]/);
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by ourselves', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      message: 'something wrong happened',
+    };
+    _client.deletePersistentMenu.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await deletePersistentMenu(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+});
diff --git a/packages/bottender/src/cli/providers/messenger/__tests__/deleteWhitelistedDomains.spec.ts b/packages/bottender/src/cli/providers/messenger/__tests__/deleteWhitelistedDomains.spec.ts
new file mode 100644
index 00000000..5bad7bbf
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/__tests__/deleteWhitelistedDomains.spec.ts
@@ -0,0 +1,112 @@
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../../shared/getConfig';
+import { deleteWhitelistedDomains } from '../whitelisted-domains';
+import * as log from '../../../shared/log';
+
+jest.mock('messaging-api-messenger');
+
+jest.mock('../../../shared/log');
+jest.mock('../../../shared/getConfig');
+
+const MOCK_FILE_WITH_PLATFORM = {
+  channels: {
+    messenger: {
+      accessToken: '__FAKE_TOKEN__',
+    },
+  },
+};
+
+let _client;
+
+beforeEach(() => {
+  _client = {
+    deleteWhitelistedDomains: jest.fn(),
+  };
+  MessengerClient.connect = jest.fn(() => _client);
+  log.error = jest.fn();
+  log.print = jest.fn();
+  getConfig.mockReturnValue(MOCK_FILE_WITH_PLATFORM.channels.messenger);
+});
+
+it('be defined', () => {
+  expect(deleteWhitelistedDomains).toBeDefined();
+});
+
+describe('resolved', () => {
+  it('call deleteWhitelistedDomains', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.deleteWhitelistedDomains.mockResolvedValue();
+
+    await deleteWhitelistedDomains(ctx);
+
+    expect(_client.deleteWhitelistedDomains).toBeCalled();
+  });
+});
+
+describe('reject', () => {
+  it('handle error thrown with only status', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+      },
+    };
+    _client.deleteWhitelistedDomains.mockRejectedValue(error);
+    process.exit = jest.fn();
+
+    await deleteWhitelistedDomains(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by messenger', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+        data: {
+          error: {
+            message: '(#100) ...',
+            type: 'OAuthException',
+            code: 100,
+            error_subcode: 2018145,
+            fbtrace_id: 'HXd3kIOXLsK',
+          },
+        },
+      },
+    };
+    _client.deleteWhitelistedDomains.mockRejectedValue(error);
+    process.exit = jest.fn();
+
+    await deleteWhitelistedDomains(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(log.error.mock.calls[2][0]).not.toMatch(/\[object Object\]/);
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by ourselves', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      message: 'something wrong happened',
+    };
+    _client.deleteWhitelistedDomains.mockRejectedValue(error);
+    process.exit = jest.fn();
+
+    await deleteWhitelistedDomains(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+});
diff --git a/packages/bottender/src/cli/providers/messenger/__tests__/getGetStarted.spec.ts b/packages/bottender/src/cli/providers/messenger/__tests__/getGetStarted.spec.ts
new file mode 100644
index 00000000..d2dd58d0
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/__tests__/getGetStarted.spec.ts
@@ -0,0 +1,130 @@
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../../shared/getConfig';
+import { getGetStarted } from '../get-started';
+import * as log from '../../../shared/log';
+
+jest.mock('messaging-api-messenger');
+
+jest.mock('../../../shared/log');
+jest.mock('../../../shared/getConfig');
+
+const MOCK_FILE_WITH_PLATFORM = {
+  channels: {
+    messenger: {
+      accessToken: '__FAKE_TOKEN__',
+    },
+  },
+};
+
+let _client;
+
+beforeEach(() => {
+  _client = {
+    getGetStarted: jest.fn(),
+  };
+  MessengerClient.connect = jest.fn(() => _client);
+  log.error = jest.fn();
+  log.print = jest.fn();
+  getConfig.mockReturnValue(MOCK_FILE_WITH_PLATFORM.channels.messenger);
+});
+
+it('be defined', () => {
+  expect(getGetStarted).toBeDefined();
+});
+
+describe('resolved', () => {
+  it('call getGetStarted', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.getGetStarted.mockResolvedValue({
+      payload: 'get started yo!',
+    });
+
+    await getGetStarted(ctx);
+
+    expect(_client.getGetStarted).toBeCalled();
+  });
+
+  it('error when no config setting', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.getGetStarted.mockResolvedValue(null);
+
+    await getGetStarted(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(_client.getGetStarted).toBeCalled();
+  });
+});
+
+describe('reject', () => {
+  it('handle error thrown with only status', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+      },
+    };
+    _client.getGetStarted.mockRejectedValueOnce(error);
+
+    process.exit = jest.fn();
+
+    await getGetStarted(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by messenger', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+        data: {
+          error: {
+            message: '(#100) ...',
+            type: 'OAuthException',
+            code: 100,
+            error_subcode: 2018145,
+            fbtrace_id: 'HXd3kIOXLsK',
+          },
+        },
+      },
+    };
+    _client.getGetStarted.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getGetStarted(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(log.error.mock.calls[2][0]).not.toMatch(/\[object Object\]/);
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by ourselves', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      message: 'something wrong happened',
+    };
+    _client.getGetStarted.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getGetStarted(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+});
diff --git a/packages/bottender/src/cli/providers/messenger/__tests__/getGreeting.spec.ts b/packages/bottender/src/cli/providers/messenger/__tests__/getGreeting.spec.ts
new file mode 100644
index 00000000..15e418df
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/__tests__/getGreeting.spec.ts
@@ -0,0 +1,131 @@
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../../shared/getConfig';
+import { getGreeting } from '../greeting';
+import * as log from '../../../shared/log';
+
+jest.mock('messaging-api-messenger');
+
+jest.mock('../../../shared/log');
+jest.mock('../../../shared/getConfig');
+
+const MOCK_FILE_WITH_PLATFORM = {
+  channels: {
+    messenger: {
+      accessToken: '__FAKE_TOKEN__',
+    },
+  },
+};
+
+let _client;
+
+beforeEach(() => {
+  _client = {
+    getGreeting: jest.fn(),
+  };
+  MessengerClient.connect = jest.fn(() => _client);
+  log.error = jest.fn();
+  log.print = jest.fn();
+  getConfig.mockReturnValue(MOCK_FILE_WITH_PLATFORM.channels.messenger);
+});
+
+it('be defined', () => {
+  expect(getGreeting).toBeDefined();
+});
+
+describe('resolved', () => {
+  it('call getGreeting', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.getGreeting.mockResolvedValue([{ text: 'hello' }]);
+
+    await getGreeting(ctx);
+
+    expect(_client.getGreeting).toBeCalled();
+  });
+
+  it('error when no config setting', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.getGreeting.mockResolvedValue([]);
+
+    await getGreeting(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(_client.getGreeting).toBeCalled();
+  });
+});
+
+describe('reject', () => {
+  it('handle error thrown with only status', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    const error = {
+      response: {
+        status: 400,
+      },
+    };
+    _client.getGreeting.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getGreeting(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by messenger', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    const error = {
+      response: {
+        status: 400,
+        data: {
+          error: {
+            message: '(#100) ...',
+            type: 'OAuthException',
+            code: 100,
+            error_subcode: 2018145,
+            fbtrace_id: 'HXd3kIOXLsK',
+          },
+        },
+      },
+    };
+    _client.getGreeting.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getGreeting(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(log.error.mock.calls[2][0]).not.toMatch(/\[object Object\]/);
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by ourselves', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    const error = {
+      message: 'something wrong happened',
+    };
+    _client.getGreeting.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getGreeting(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+});
diff --git a/packages/bottender/src/cli/providers/messenger/__tests__/getPersistentMenu.spec.ts b/packages/bottender/src/cli/providers/messenger/__tests__/getPersistentMenu.spec.ts
new file mode 100644
index 00000000..d786f9b0
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/__tests__/getPersistentMenu.spec.ts
@@ -0,0 +1,140 @@
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../../shared/getConfig';
+import { getPersistentMenu } from '../persistent-menu';
+import * as log from '../../../shared/log';
+
+jest.mock('messaging-api-messenger');
+
+jest.mock('../../../shared/log');
+jest.mock('../../../shared/getConfig');
+
+const MOCK_FILE_WITH_PLATFORM = {
+  channels: {
+    messenger: {
+      accessToken: '__FAKE_TOKEN__',
+    },
+  },
+};
+
+let _client;
+
+beforeEach(() => {
+  _client = {
+    getPersistentMenu: jest.fn(),
+  };
+  MessengerClient.connect = jest.fn(() => _client);
+  log.error = jest.fn();
+  log.print = jest.fn();
+  console.log = jest.fn();
+  getConfig.mockReturnValue(MOCK_FILE_WITH_PLATFORM.channels.messenger);
+});
+
+it('be defined', () => {
+  expect(getPersistentMenu).toBeDefined();
+});
+
+describe('resolved', () => {
+  it('call getPersistentMenu', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.getPersistentMenu.mockResolvedValue([
+      {
+        composer_input_disabled: false,
+        call_to_actions: [
+          {
+            type: 'postback',
+            title: 'RESTART',
+            payload: 'RESTART',
+          },
+        ],
+      },
+    ]);
+
+    await getPersistentMenu(ctx);
+
+    expect(_client.getPersistentMenu).toBeCalled();
+  });
+
+  it('error when no config setting', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.getPersistentMenu.mockResolvedValue([]);
+
+    await getPersistentMenu(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(_client.getPersistentMenu).toBeCalled();
+  });
+});
+
+describe('reject', () => {
+  it('handle error thrown with only status', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+      },
+    };
+    _client.getPersistentMenu.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getPersistentMenu(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by messenger', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+        data: {
+          error: {
+            message: '(#100) ...',
+            type: 'OAuthException',
+            code: 100,
+            error_subcode: 2018145,
+            fbtrace_id: 'HXd3kIOXLsK',
+          },
+        },
+      },
+    };
+    _client.getPersistentMenu.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getPersistentMenu(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(log.error.mock.calls[2][0]).not.toMatch(/\[object Object\]/);
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by ourselves', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      message: 'something wrong happened',
+    };
+    _client.getPersistentMenu.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getPersistentMenu(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+});
diff --git a/packages/bottender/src/cli/providers/messenger/__tests__/getWhitelistedDomains.spec.ts b/packages/bottender/src/cli/providers/messenger/__tests__/getWhitelistedDomains.spec.ts
new file mode 100644
index 00000000..27d4a60b
--- /dev/null
+++ b/packages/bottender/src/cli/providers/messenger/__tests__/getWhitelistedDomains.spec.ts
@@ -0,0 +1,131 @@
+import { MessengerClient } from 'messaging-api-messenger';
+
+import getConfig from '../../../shared/getConfig';
+import { getWhitelistedDomains } from '../whitelisted-domains';
+import * as log from '../../../shared/log';
+
+jest.mock('messaging-api-messenger');
+
+jest.mock('../../../shared/log');
+jest.mock('../../../shared/getConfig');
+
+const MOCK_FILE_WITH_PLATFORM = {
+  channels: {
+    messenger: {
+      accessToken: '__FAKE_TOKEN__',
+    },
+  },
+};
+
+let _client;
+
+beforeEach(() => {
+  _client = {
+    getWhitelistedDomains: jest.fn(),
+  };
+  MessengerClient.connect = jest.fn(() => _client);
+  log.error = jest.fn();
+  log.print = jest.fn();
+  getConfig.mockReturnValue(MOCK_FILE_WITH_PLATFORM.channels.messenger);
+});
+
+it('be defined', () => {
+  expect(getWhitelistedDomains).toBeDefined();
+});
+
+describe('resolved', () => {
+  it('call getWhitelistedDomains', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.getWhitelistedDomains.mockResolvedValue([
+      'http://www.facebook.com',
+      'http://www.yoctol.com',
+    ]);
+
+    await getWhitelistedDomains(ctx);
+
+    expect(_client.getWhitelistedDomains).toBeCalled();
+  });
+
+  it('error when no config setting', async () => {
+    const ctx = {
+      argv: {},
+    };
+
+    _client.getWhitelistedDomains.mockResolvedValue(null);
+
+    await getWhitelistedDomains(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(_client.getWhitelistedDomains).toBeCalled();
+  });
+});
+
+describe('reject', () => {
+  it('handle error thrown with only status', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+      },
+    };
+    _client.getWhitelistedDomains.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getWhitelistedDomains(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by messenger', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      response: {
+        status: 400,
+        data: {
+          error: {
+            message: '(#100) ...',
+            type: 'OAuthException',
+            code: 100,
+            error_subcode: 2018145,
+            fbtrace_id: 'HXd3kIOXLsK',
+          },
+        },
+      },
+    };
+    _client.getWhitelistedDomains.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getWhitelistedDomains(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(log.error.mock.calls[2][0]).not.toMatch(/\[object Object\]/);
+    expect(process.exit).toBeCalled();
+  });
+
+  it('handle error thrown by ourselves', async () => {
+    const ctx = {
+      argv: {},
+    };
+    const error = {
+      message: 'something wrong happened',
+    };
+    _client.getWhitelistedDomains.mockRejectedValue(error);
+
+    process.exit = jest.fn();
+
+    await getWhitelistedDomains(ctx);
+
+    expect(log.error).toBeCalled();
+    expect(process.exit).toBeCalled();
+  });
+});
diff --git a/packages/bottender/src/cli/providers/messenger/__tests__/index.spec.ts b/packages/bottender/src/cli/providers/messenger/__tests__/index.spec.ts
index 8988e23c..f6c3e6ba 100644
--- a/packages/bottender/src/cli/providers/messenger/__tests__/index.spec.ts
+++ b/packages/bottender/src/cli/providers/messenger/__tests__/index.spec.ts
@@ -1,7 +1,11 @@
 import messenger from '..';
 
+jest.mock('../get-started');
+jest.mock('../greeting');
+jest.mock('../persistent-menu');
 jest.mock('../profile');
 jest.mock('../webhook');
+jest.mock('../whitelisted-domains');
 jest.mock('../help');
 jest.mock('../persona');
 
@@ -10,6 +14,21 @@ describe('messenger cli', () => {
     expect(messenger).toBeDefined();
   });
 
+  it('should return getStarted module', () => {
+    const getStarted = require('../get-started').default;
+    expect(messenger.getStarted).toEqual(getStarted);
+  });
+
+  it('should return greeting module', () => {
+    const greeting = require('../greeting').default;
+    expect(messenger.greeting).toEqual(greeting);
+  });
+
+  it('should return persistentMenu module', () => {
+    const persistentMenu = require('../persistent-menu').default;
+    expect(messenger.persistentMenu).toEqual(persistentMenu);
+  });
+
   it('should return profile module', () => {
     const profile = require('../profile').default;
     expect(messenger.profile).toEqual(profile);
@@ -20,11 +39,26 @@ describe('messenger cli', () => {
     expect(messenger.webhook).toEqual(webhook);
   });
 
+  it('should return whitelistedDomains module', () => {
+    const whitelistedDomains = require('../whitelisted-domains').default;
+    expect(messenger.whitelistedDomains).toEqual(whitelistedDomains);
+  });
+
   it('should return help module', () => {
     const help = require('../help').default;
     expect(messenger.help).toEqual(help);
   });
 
+  it('should return persistentMenu module when gettig menu', () => {
+    const persistentMenu = require('../persistent-menu').default;
+    expect(messenger.menu).toEqual(persistentMenu);
+  });
+
+  it('should return whitelistedDomains module when gettig domains', () => {
+    const whitelistedDomains = require('../whitelisted-domains').default;
+    expect(messenger.domains).toEqual(whitelistedDomains);
+  });
+
   it('should return persona module', () => {
     const persona = require('../persona').default;
     expect(messenger.persona).toEqual(persona);
diff --git a/packages/bottender/src/cli/providers/messenger/__tests__/setWebhook.spec.ts b/packages/bottender/src/cli/providers/messenger/__tests__/setWebhook.spec.ts
index b915f5ae..de79ddd2 100644
--- a/packages/bottender/src/cli/providers/messenger/__tests__/setWebhook.spec.ts
+++ b/packages/bottender/src/cli/providers/messenger/__tests__/setWebhook.spec.ts
@@ -116,7 +116,7 @@ describe('resolve', () => {
 
     await setWebhook(ctx);
 
-    expect(getWebhookFromNgrok).toBeCalledWith('4040');
+    expect(getWebhookFromNgrok).toBeCalledWith(undefined);
     expect(client.createSubscription).toBeCalledWith({
       access_token: '__APP_ID__|__APP_SECRET__',
       callback_url: 'https://fakeDomain.ngrok.io',
diff --git a/packages/bottender/src/cli/providers/telegram/__tests__/setWebhook.spec.ts b/packages/bottender/src/cli/providers/telegram/__tests__/setWebhook.spec.ts
index 3537d253..463edd8d 100644
--- a/packages/bottender/src/cli/providers/telegram/__tests__/setWebhook.spec.ts
+++ b/packages/bottender/src/cli/providers/telegram/__tests__/setWebhook.spec.ts
@@ -69,7 +69,7 @@ describe('resolve', () => {
 
     await setWebhook(ctx);
 
-    expect(getWebhookFromNgrok).toBeCalledWith('4040');
+    expect(getWebhookFromNgrok).toBeCalledWith(undefined);
     expect(log.print).toHaveBeenCalledTimes(1);
     expect(log.print.mock.calls[0][0]).toMatch(/Successfully/);
   });
diff --git a/packages/bottender/src/cli/providers/viber/__tests__/setWebhook.spec.ts b/packages/bottender/src/cli/providers/viber/__tests__/setWebhook.spec.ts
index 07c9fa75..40310ee5 100644
--- a/packages/bottender/src/cli/providers/viber/__tests__/setWebhook.spec.ts
+++ b/packages/bottender/src/cli/providers/viber/__tests__/setWebhook.spec.ts
@@ -102,7 +102,7 @@ describe('resolve', () => {
 
     await setWebhook(ctx);
 
-    expect(getWebhookFromNgrok).toBeCalledWith('4040');
+    expect(getWebhookFromNgrok).toBeCalledWith(undefined);
     expect(client.setWebhook).toBeCalledWith(
       'https://fakeDomain.ngrok.io',
       undefined
diff --git a/packages/bottender/src/cli/shared/__tests__/getWebhookFromNgrok.spec.ts b/packages/bottender/src/cli/shared/__tests__/getWebhookFromNgrok.spec.ts
index a3796589..b8c0f9b7 100644
--- a/packages/bottender/src/cli/shared/__tests__/getWebhookFromNgrok.spec.ts
+++ b/packages/bottender/src/cli/shared/__tests__/getWebhookFromNgrok.spec.ts
@@ -4,29 +4,110 @@ import getWebhookFromNgrok from '../getWebhookFromNgrok';
 
 jest.mock('axios');
 
+const res = {
+  data: {
+    tunnels: [
+      {
+        name: 'command_line',
+        uri: '/api/tunnels/command_line',
+        public_url: 'https://d95211d2.ngrok.io',
+        proto: 'https',
+        config: {
+          addr: 'localhost:80',
+          inspect: true,
+        },
+        metrics: {
+          conns: {
+            count: 0,
+            gauge: 0,
+            rate1: 0,
+            rate5: 0,
+            rate15: 0,
+            p50: 0,
+            p90: 0,
+            p95: 0,
+            p99: 0,
+          },
+          http: {
+            count: 0,
+            rate1: 0,
+            rate5: 0,
+            rate15: 0,
+            p50: 0,
+            p90: 0,
+            p95: 0,
+            p99: 0,
+          },
+        },
+      },
+      {
+        name: 'command_line',
+        uri: '/api/tunnels/command_line',
+        public_url: 'http://d95211d2.ngrok.io',
+        proto: 'http',
+        config: {
+          addr: 'localhost:80',
+          inspect: true,
+        },
+        metrics: {
+          conns: {
+            count: 0,
+            gauge: 0,
+            rate1: 0,
+            rate5: 0,
+            rate15: 0,
+            p50: 0,
+            p90: 0,
+            p95: 0,
+            p99: 0,
+          },
+          http: {
+            count: 0,
+            rate1: 0,
+            rate5: 0,
+            rate15: 0,
+            p50: 0,
+            p90: 0,
+            p95: 0,
+            p99: 0,
+          },
+        },
+      },
+    ],
+  },
+};
+
 it('be defined', () => {
   expect(getWebhookFromNgrok).toBeDefined();
 });
 
-it('call axios.create and return correct url', async () => {
-  axios.create.mockReturnValue({
-    get: jest.fn().mockResolvedValue({
-      data: {
-        tunnels: [
-          {
-            public_url: 'http://fakeDomain_1.ngrok.io',
-          },
-          {
-            public_url: 'https://fakeDomain_2.ngrok.io',
-          },
-        ],
-      },
-    }),
+it('create axios with 4040 port and return the correct url', async () => {
+  const axiosInstance = {
+    get: jest.fn().mockResolvedValue(res),
+  };
+
+  axios.create.mockReturnValue(axiosInstance);
+
+  const webhook = await getWebhookFromNgrok();
+
+  expect(webhook).toBe('https://d95211d2.ngrok.io');
+
+  expect(axios.create).toBeCalledWith({
+    baseURL: 'http://localhost:4040',
   });
+});
+
+it('create axios with specified port and return correct url', async () => {
+  const axiosInstance = {
+    get: jest.fn().mockResolvedValue(res),
+  };
+
+  axios.create.mockReturnValue(axiosInstance);
 
   const webhook = await getWebhookFromNgrok('5555');
 
-  expect(webhook).toBe('https://fakeDomain_2.ngrok.io');
+  expect(webhook).toBe('https://d95211d2.ngrok.io');
+
   expect(axios.create).toBeCalledWith({
     baseURL: 'http://localhost:5555',
   });
diff --git a/packages/bottender/src/cli/shared/__tests__/path.spec.ts b/packages/bottender/src/cli/shared/__tests__/path.spec.ts
new file mode 100644
index 00000000..9ec2d2f3
--- /dev/null
+++ b/packages/bottender/src/cli/shared/__tests__/path.spec.ts
@@ -0,0 +1,18 @@
+import path from 'path';
+
+import { toAbsolutePath } from '../path';
+
+describe('#toAbsolutePath', () => {
+  it('should return absolute path directly', () => {
+    expect(toAbsolutePath('/foo/bar')).toBe('/foo/bar');
+    expect(toAbsolutePath('/baz/..')).toBe('/baz/..');
+    expect(toAbsolutePath('~/')).toBe('~/');
+  });
+
+  it('should transform relative path to absolute path', () => {
+    const cwd = process.cwd();
+    expect(toAbsolutePath('.')).toBe(`${cwd}`);
+    expect(toAbsolutePath('..')).toBe(path.join(cwd, '..'));
+    expect(toAbsolutePath('bar/baz')).toBe(path.join(cwd, 'bar', 'baz'));
+  });
+});