Repository: Yoctol/bottender
Pull request #462 (2019-10-04 07:32:01)

feat(router): add bottender/router
This is the first version draft about the router we want to introduce in the Bottender v1. It helps to branch the conversation flow into different kinds of actions based on routes.

```js
import router, { route, text, payload } from 'bottender/router';

async function SendHiBack(context) {
  await context.sendText('Hi');
} 

async function SendHello(context) {
  await context.sendText('Hi');
} 

async function SendHello(context) {
  await context.sendText('abc');
} 

async function SendDefaultResponse(context) {
  await context.sendText('I dont understand.');
}

async function CatchAll(context) {
  await context.sendText('catch all');
}

const Router = router([
  text('Hi', SendHiBack),
  text(['Hello', 'Hey'], SendHello),
  text(/abc/, SendAbcBack),
  text('*', SendDefaultResponse),
  payload('Hi', SendHiBack),
  route('*', CatchAll),
]);
```

`route` function returns an action that accepts context as well.


Patch:
diff --git a/packages/bottender/package.json b/packages/bottender/package.json
index b2279faa..fdf4839b 100644
--- a/packages/bottender/package.json
+++ b/packages/bottender/package.json
@@ -17,6 +17,7 @@
   "files": [
     "bin",
     "dist",
+    "router.js",
     "test-utils.js",
     "utils.js"
   ],
diff --git a/packages/bottender/router.js b/packages/bottender/router.js
new file mode 100644
index 00000000..b750e524
--- /dev/null
+++ b/packages/bottender/router.js
@@ -0,0 +1,2 @@
+/* eslint-disable import/no-unresolved */
+module.exports = require('./dist/router');
diff --git a/packages/bottender/src/router/index.ts b/packages/bottender/src/router/index.ts
new file mode 100644
index 00000000..31164362
--- /dev/null
+++ b/packages/bottender/src/router/index.ts
@@ -0,0 +1,123 @@
+import Context from '../context/Context';
+
+type MatchPattern = string | Array<string> | RegExp;
+
+type RoutePattern = '*' | RoutePredicate;
+
+type RoutePredicate = (context: Context) => boolean | Promise<boolean>;
+
+type Action = (
+  context: Context,
+  props?: Props
+) => void | Action | Promise<Action>;
+
+type Route = {
+  predicate: RoutePredicate;
+  action: Action;
+};
+
+type Props = {
+  next?: Action;
+  [key: string]: any;
+};
+
+function router(routes: Route[]) {
+  return async function Router(context: Context, { next }: Props = {}) {
+    for (const r of routes) {
+      // eslint-disable-next-line no-await-in-loop
+      if (await r.predicate(context)) {
+        return r.action;
+      }
+    }
+
+    return next;
+  };
+}
+
+function route(pattern: RoutePattern, action: Action) {
+  if (pattern === '*') {
+    return {
+      predicate: () => true,
+      action,
+    };
+  }
+
+  return {
+    predicate: pattern,
+    action,
+  };
+}
+
+function text(pattern: MatchPattern, action: Action) {
+  if (typeof pattern === 'string') {
+    if (pattern === '*') {
+      return {
+        predicate: (context: Context) => context.event.isText,
+        action,
+      };
+    }
+
+    return {
+      predicate: (context: Context) => context.event.text === pattern,
+      action,
+    };
+  }
+
+  if (pattern instanceof RegExp) {
+    return {
+      predicate: (context: Context) => pattern.test(context.event.text),
+      action,
+    };
+  }
+
+  if (Array.isArray(pattern)) {
+    return {
+      predicate: (context: Context) => pattern.includes(context.event.text),
+      action,
+    };
+  }
+
+  return {
+    predicate: () => false,
+    action,
+  };
+}
+
+function payload(pattern: MatchPattern, action: Action) {
+  if (typeof pattern === 'string') {
+    if (pattern === '*') {
+      return {
+        predicate: (context: Context) => context.event.isPayload,
+        action,
+      };
+    }
+
+    return {
+      predicate: (context: Context) => context.event.payload === pattern,
+      action,
+    };
+  }
+
+  if (pattern instanceof RegExp) {
+    return {
+      predicate: (context: Context) => pattern.test(context.event.payload),
+      action,
+    };
+  }
+
+  if (Array.isArray(pattern)) {
+    return {
+      predicate: (context: Context) => pattern.includes(context.event.payload),
+      action,
+    };
+  }
+
+  return {
+    predicate: () => false,
+    action,
+  };
+}
+
+export default router;
+
+export { router, route, text, payload };


Test patch:
diff --git a/packages/bottender/src/router/__tests__/router.spec.ts b/packages/bottender/src/router/__tests__/router.spec.ts
new file mode 100644
index 00000000..e8e1447b
--- /dev/null
+++ b/packages/bottender/src/router/__tests__/router.spec.ts
@@ -0,0 +1,193 @@
+import router, { payload, route, text } from '..';
+
+const contextWithTextHi = {
+  event: {
+    isText: true,
+    isPayload: false,
+    text: 'hi',
+  },
+};
+
+const contextWithTextHello = {
+  event: {
+    isText: true,
+    isPayload: false,
+    text: 'hello',
+  },
+};
+
+const contextWithTextHey = {
+  event: {
+    isText: true,
+    isPayload: false,
+    text: 'hey',
+  },
+};
+
+const contextWithPayloadHi = {
+  event: {
+    isText: false,
+    isPayload: true,
+    payload: 'hi',
+  },
+};
+
+const contextWithPayloadHello = {
+  event: {
+    isText: false,
+    isPayload: true,
+    payload: 'hello',
+  },
+};
+
+const contextWithPayloadHey = {
+  event: {
+    isText: false,
+    isPayload: true,
+    payload: 'hey',
+  },
+};
+
+describe('#router', () => {
+  it('should work with raw route format', async () => {
+    const Hi = () => {};
+
+    const Router = router([
+      {
+        predicate: () => true,
+        action: Hi,
+      },
+    ]);
+
+    expect(await Router(contextWithTextHi as any)).toEqual(Hi);
+  });
+
+  it('should return next if not match any route', async () => {
+    const Hi = () => {};
+    const Next = () => {};
+
+    const Router = router([
+      {
+        predicate: () => false,
+        action: Hi,
+      },
+    ]);
+
+    expect(await Router(contextWithTextHi as any, { next: Next })).toEqual(
+      Next
+    );
+  });
+});
+
+describe('#route', () => {
+  it('should work with *', async () => {
+    const Hi = () => {};
+
+    const Router = router([route('*', Hi)]);
+
+    expect(await Router(contextWithTextHi as any)).toEqual(Hi);
+    expect(await Router(contextWithPayloadHi as any)).toEqual(Hi);
+  });
+
+  it('should work with predicate', async () => {
+    const Hi = () => {};
+
+    const Router = router([
+      route(
+        context => context.event.isText && context.event.text.startsWith('h'),
+        Hi
+      ),
+    ]);
+
+    expect(await Router(contextWithTextHi as any)).toEqual(Hi);
+    expect(await Router(contextWithPayloadHi as any)).toBeUndefined();
+  });
+});
+
+describe('#text', () => {
+  it('should work with string', async () => {
+    const Hi = () => {};
+
+    const Router = router([text('hi', Hi)]);
+
+    expect(await Router(contextWithTextHi as any)).toEqual(Hi);
+
+    expect(await Router(contextWithTextHello as any)).toBeUndefined();
+  });
+
+  it('should work with array of string', async () => {
+    const Hi = () => {};
+
+    const Router = router([text(['hi', 'hello'], Hi)]);
+
+    expect(await Router(contextWithTextHi as any)).toEqual(Hi);
+    expect(await Router(contextWithTextHello as any)).toEqual(Hi);
+
+    expect(await Router(contextWithTextHey as any)).toBeUndefined();
+  });
+
+  it('should work with regexp', async () => {
+    const Hi = () => {};
+
+    const Router = router([text(/(hi|hello)/, Hi)]);
+
+    expect(await Router(contextWithTextHi as any)).toEqual(Hi);
+    expect(await Router(contextWithTextHello as any)).toEqual(Hi);
+
+    expect(await Router(contextWithTextHey as any)).toBeUndefined();
+  });
+
+  it('should work with *', async () => {
+    const Hi = () => {};
+
+    const Router = router([text('*', Hi)]);
+
+    expect(await Router(contextWithTextHi as any)).toEqual(Hi);
+    expect(await Router(contextWithTextHello as any)).toEqual(Hi);
+    expect(await Router(contextWithTextHey as any)).toEqual(Hi);
+  });
+});
+
+describe('#payload', () => {
+  it('should work with string', async () => {
+    const Hi = () => {};
+
+    const Router = router([payload('hi', Hi)]);
+
+    expect(await Router(contextWithPayloadHi as any)).toEqual(Hi);
+
+    expect(await Router(contextWithPayloadHello as any)).toBeUndefined();
+  });
+
+  it('should work with array of string', async () => {
+    const Hi = () => {};
+
+    const Router = router([payload(['hi', 'hello'], Hi)]);
+
+    expect(await Router(contextWithPayloadHi as any)).toEqual(Hi);
+    expect(await Router(contextWithPayloadHello as any)).toEqual(Hi);
+
+    expect(await Router(contextWithPayloadHey as any)).toBeUndefined();
+  });
+
+  it('should work with regexp', async () => {
+    const Hi = () => {};
+
+    const Router = router([payload(/(hi|hello)/, Hi)]);
+
+    expect(await Router(contextWithPayloadHi as any)).toEqual(Hi);
+    expect(await Router(contextWithPayloadHello as any)).toEqual(Hi);
+
+    expect(await Router(contextWithPayloadHey as any)).toBeUndefined();
+  });
+
+  it('should work with *', async () => {
+    const Hi = () => {};
+
+    const Router = router([payload('*', Hi)]);
+
+    expect(await Router(contextWithPayloadHi as any)).toEqual(Hi);
+    expect(await Router(contextWithPayloadHello as any)).toEqual(Hi);
+    expect(await Router(contextWithPayloadHey as any)).toEqual(Hi);
+  });
+});