Repository: Yoctol/bottender
Pull request #781 (2020-05-25 06:46:21)

feat(Server): support custom connectors
- Support custom connectors in `bottender.config.js`.
- Add some tests for `Server.ts`.

codecov-commenter: # [Codecov](https://codecov.io/gh/Yoctol/bottender/pull/781?src=pr&el=h1) Report
> Merging [#781](https://codecov.io/gh/Yoctol/bottender/pull/781?src=pr&el=desc) into [release/1.5](https://codecov.io/gh/Yoctol/bottender/commit/c5ab7f09a8921b5c3172ec8ce4ee6846f6a0c461&el=desc) will **increase** coverage by `0.93%`.
> The diff coverage is `85.71%`.

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

```diff
@@               Coverage Diff               @@
##           release/1.5     #781      +/-   ##
===============================================
+ Coverage        81.08%   82.01%   +0.93%     
===============================================
  Files              108      108              
  Lines             4747     4754       +7     
  Branches          1197     1153      -44     
===============================================
+ Hits              3849     3899      +50     
+ Misses             862      825      -37     
+ Partials            36       30       -6     
```


| [Impacted Files](https://codecov.io/gh/Yoctol/bottender/pull/781?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/bottender/src/types.ts](https://codecov.io/gh/Yoctol/bottender/pull/781/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy90eXBlcy50cw==) | `100.00% <ø> (ø)` | |
| [packages/bottender/src/server/Server.ts](https://codecov.io/gh/Yoctol/bottender/pull/781/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9zZXJ2ZXIvU2VydmVyLnRz) | `63.44% <85.71%> (+52.97%)` | :arrow_up: |
| [packages/bottender/src/chain.ts](https://codecov.io/gh/Yoctol/bottender/pull/781/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9jaGFpbi50cw==) | `76.92% <0.00%> (ø)` | |
| [packages/bottender/src/getClient.ts](https://codecov.io/gh/Yoctol/bottender/pull/781/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9nZXRDbGllbnQudHM=) | `55.55% <0.00%> (ø)` | |
| [packages/bottender/src/router/index.ts](https://codecov.io/gh/Yoctol/bottender/pull/781/diff?src=pr&el=tree#diff-cGFja2FnZXMvYm90dGVuZGVyL3NyYy9yb3V0ZXIvaW5kZXgudHM=) | `90.66% <0.00%> (ø)` | |
| [packages/create-bottender-app/src/shared/log.ts](https://codecov.io/gh/Yoctol/bottender/pull/781/diff?src=pr&el=tree#diff-cGFja2FnZXMvY3JlYXRlLWJvdHRlbmRlci1hcHAvc3JjL3NoYXJlZC9sb2cudHM=) | `0.00% <0.00%> (ø)` | |

------

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


Patch:
diff --git a/packages/bottender/src/server/Server.ts b/packages/bottender/src/server/Server.ts
index 81858027..5df962bb 100644
--- a/packages/bottender/src/server/Server.ts
+++ b/packages/bottender/src/server/Server.ts
@@ -3,6 +3,7 @@ import url from 'url';
 import { IncomingMessage, ServerResponse } from 'http';
 
 import fromEntries from 'fromentries';
+import invariant from 'invariant';
 import merge from 'lodash/merge';
 import { match } from 'path-to-regexp';
 import { pascalcase } from 'messaging-api-common';
@@ -41,7 +42,7 @@ class Server {
   public async prepare(): Promise<void> {
     const bottenderConfig = getBottenderConfig();
 
-    const { initialState, plugins, channels } = merge(
+    const { initialState, plugins, channels = {} } = merge(
       bottenderConfig /* , config */
     ) as BottenderConfig;
 
@@ -88,24 +89,49 @@ class Server {
       return;
     }
 
-    const channelBots = Object.entries(channels || {})
+    const channelBots = (Object.entries(channels) as [string, any][])
       .filter(([, { enabled }]) => enabled)
-      .map(([channel, { path: webhookPath, ...channelConfig }]) => {
-        // eslint-disable-next-line import/no-dynamic-require
-        const ChannelBot = require(`../${channel}/${pascalcase(channel)}Bot`)
-          .default;
-        const channelBot = new ChannelBot({
-          ...channelConfig,
-          sessionStore,
-        }) as Bot<any, any, any, any>;
-
-        initializeBot(channelBot);
-
-        return {
-          webhookPath: webhookPath || `/webhooks/${channel}`,
-          bot: channelBot,
-        };
-      });
+      .map(
+        ([
+          channel,
+          { path: webhookPath, sync, onRequest, connector, ...connectorConfig },
+        ]) => {
+          let channelConnector;
+          if (
+            [
+              'messenger',
+              'line',
+              'telegram',
+              'slack',
+              'viber',
+              'whatsapp',
+            ].includes(channel)
+          ) {
+            // eslint-disable-next-line import/no-dynamic-require
+            const ChannelConnector = require(`../${channel}/${pascalcase(
+              channel
+            )}Connector`).default;
+            channelConnector = new ChannelConnector(connectorConfig);
+          } else {
+            invariant(connector, `The connector of ${channel} is missing.`);
+            channelConnector = connector;
+          }
+
+          const channelBot = new Bot({
+            sessionStore,
+            sync,
+            onRequest,
+            connector: channelConnector,
+          }) as Bot<any, any, any, any>;
+
+          initializeBot(channelBot);
+
+          return {
+            webhookPath: webhookPath || `/webhooks/${channel}`,
+            bot: channelBot,
+          };
+        }
+      );
 
     this._channelBots = channelBots;
   }
diff --git a/packages/bottender/src/types.ts b/packages/bottender/src/types.ts
index 66e5d171..291920df 100644
--- a/packages/bottender/src/types.ts
+++ b/packages/bottender/src/types.ts
@@ -17,6 +17,7 @@ import TelegramEvent from './telegram/TelegramEvent';
 import TwilioClient from './whatsapp/TwilioClient';
 import ViberEvent from './viber/ViberEvent';
 import WhatsappEvent from './whatsapp/WhatsappEvent';
+import { Connector } from './bot/Connector';
 import { ConsoleClient } from './console/ConsoleClient';
 import { LineConnectorOptions, LineRequestBody } from './line/LineConnector';
 import {
@@ -125,7 +126,7 @@ export type SessionConfig = {
         };
       }
     | {
-        [P in Exclude<string, SessionDriver>]: SessionStore;
+        [P in Exclude<string, SessionDriver>]?: SessionStore;
       };
 };
 
@@ -133,21 +134,30 @@ type ChannelCommonConfig = {
   enabled: boolean;
   path?: string;
   sync?: boolean;
-  OnRequest?: OnRequest;
+  onRequest?: OnRequest;
 };
 
 export type BottenderConfig = {
   plugins?: Plugin<any>[];
   session?: SessionConfig;
   initialState?: Record<string, any>;
-  channels?: {
-    [Channel.Messenger]: MessengerConnectorOptions & ChannelCommonConfig;
-    [Channel.Line]: LineConnectorOptions & ChannelCommonConfig;
-    [Channel.Telegram]: TelegramConnectorOptions & ChannelCommonConfig;
-    [Channel.Slack]: SlackConnectorOptions & ChannelCommonConfig;
-    [Channel.Viber]: ViberConnectorOptions & ChannelCommonConfig;
-    [Channel.Whatsapp]: WhatsappConnectorOptions & ChannelCommonConfig;
-  };
+  channels?:
+    | {
+        messenger?: MessengerConnectorOptions & ChannelCommonConfig;
+        line?: LineConnectorOptions & ChannelCommonConfig;
+        telegram?: TelegramConnectorOptions & ChannelCommonConfig;
+        slack?: SlackConnectorOptions & ChannelCommonConfig;
+        viber?: ViberConnectorOptions & ChannelCommonConfig;
+        whatsapp?: WhatsappConnectorOptions & ChannelCommonConfig;
+      }
+    | {
+        [key in Exclude<
+          string,
+          'messenger' | 'line' | 'telegram' | 'slack' | 'viber' | 'whatsapp'
+        >]?: {
+          connector: Connector<any, any>;
+        } & ChannelCommonConfig;
+      };
 };
 
 export type RequestContext<


Test patch:
diff --git a/packages/bottender/src/server/__tests__/Server.spec.ts b/packages/bottender/src/server/__tests__/Server.spec.ts
new file mode 100644
index 00000000..5540bf83
--- /dev/null
+++ b/packages/bottender/src/server/__tests__/Server.spec.ts
@@ -0,0 +1,174 @@
+import http from 'http';
+import net from 'net';
+import path from 'path';
+
+import { JsonArray, JsonObject } from 'type-fest';
+import { mocked } from 'ts-jest/utils';
+
+import Server from '../Server';
+import getBottenderConfig from '../../shared/getBottenderConfig';
+
+let receivedContext;
+
+jest.mock('../../messenger/MessengerContext');
+jest.mock('../../shared/getBottenderConfig');
+jest.mock(
+  '/Users/username/bot/index.js',
+  () =>
+    async function App(context) {
+      receivedContext = context;
+      await context.sendText('Hello World');
+    },
+  { virtual: true }
+);
+
+const pathResolve = path.resolve;
+
+beforeEach(() => {
+  const customPathResolve = jest.fn((...args) => {
+    if (args[0] === 'index.js') return '/Users/username/bot/index.js';
+    return pathResolve(...args);
+  });
+  path.resolve = customPathResolve;
+});
+
+afterEach(() => {
+  path.resolve = pathResolve;
+});
+
+it('support built-in connectors', async () => {
+  mocked(getBottenderConfig).mockReturnValue({
+    channels: {
+      messenger: {
+        enabled: true,
+        path: '/webhooks/messenger',
+        sync: true,
+        accessToken: 'ACCESS_TOKEN',
+        appId: 'APP_ID',
+        appSecret: 'APP_SECRET',
+      },
+    },
+  });
+
+  const server = new Server();
+
+  await server.prepare();
+
+  const requestHandler = server.getRequestHandler();
+
+  const socket = new net.Socket();
+  const req: http.IncomingMessage & {
+    rawBody?: string;
+    body?: JsonObject | JsonArray;
+  } = new http.IncomingMessage(socket);
+
+  const body = {
+    object: 'page',
+    entry: [
+      {
+        id: '1895382890692545',
+        time: 1486464322257,
+        messaging: [
+          {
+            sender: {
+              id: '1412611362105802',
+            },
+            recipient: {
+              id: '1895382890692545',
+            },
+            timestamp: 1486464322190,
+            message: {
+              mid: 'mid.1486464322190:cb04e5a654',
+              seq: 339979,
+              text: 'text',
+            },
+          },
+        ],
+      },
+    ],
+  };
+
+  req.rawBody = JSON.stringify(body);
+  req.body = body;
+  req.method = 'post';
+  req.headers.host = 'www.example.com';
+  req.headers['x-hub-signature'] =
+    'sha1=0c9b77eea92817a0fa756ee52388061d18112a48';
+  req.url = '/webhooks/messenger';
+
+  const res = new http.ServerResponse(req);
+
+  await requestHandler(req, res);
+
+  expect(receivedContext.sendText).toBeCalledWith('Hello World');
+});
+
+it('support custom connectors', async () => {
+  const event = {
+    rawEvent: {
+      message: {
+        text: 'hi',
+      },
+    },
+    isMessage: true,
+    isText: true,
+    message: {
+      text: 'hi',
+    },
+  };
+
+  const session = {
+    user: {
+      id: '__id__',
+    },
+  };
+
+  const context = {
+    event,
+    session,
+    sendText: jest.fn(),
+  };
+  const connector = {
+    platform: 'any',
+    getUniqueSessionKey: jest.fn(() => '__id__'),
+    updateSession: jest.fn(),
+    mapRequestToEvents: jest.fn(() => [event]),
+    createContext: jest.fn(() => context),
+    preprocess: jest.fn(() => ({ shouldNext: true })),
+  };
+
+  mocked(getBottenderConfig).mockReturnValue({
+    channels: {
+      custom: {
+        enabled: true,
+        path: '/webhooks/custom',
+        sync: true,
+        connector,
+      },
+    },
+  });
+
+  const server = new Server();
+
+  await server.prepare();
+
+  const requestHandler = server.getRequestHandler();
+
+  const socket = new net.Socket();
+  const req: http.IncomingMessage & {
+    rawBody?: string;
+    body?: JsonObject | JsonArray;
+  } = new http.IncomingMessage(socket);
+
+  req.rawBody = '{}';
+  req.body = {};
+  req.headers.host = 'www.example.com';
+  req.method = 'post';
+  req.url = '/webhooks/custom';
+
+  const res = new http.ServerResponse(req);
+
+  await requestHandler(req, res);
+
+  expect(context.sendText).toBeCalledWith('Hello World');
+});