LINEのEchoボットをTypscriptで開発する

## はじめに

LINEのボット作成については、[LINE Developers](https://developers.line.biz/ja/)でアカウント登録やドキュメント「Message API」の「Herokuでサンプルボットを作成する」などを参考にすると簡単に動作確認までできますが、公式のEchoサンプルボットをTypescriptで開発する方法を紹介します。

## 開発環境

- VSCode
- Node.js
    - @line/bot-sdk
    - Typescript
    - ESLint
    - Jest
    - etc...
- [Glitch](https://glitch.com/)

公式ドキュメントではHerokuを利用していますが、今回Typescriptで開発するので、最近よく利用しているGlitchを動作確認環境として利用します。

## プロジェクトの作成

プロジェクト作成の為のディレクトリを作成し、`package.json`を作成します。

```shell-session
$ mkdir line-bot-starter
$ cd line-bot-starter
$ npm init -y
```

開発に必要なパッケージをインストールします。

```shell-session
$ npm install @line/bot-sdk body-parser dotenv errorhandler express morgan morgan-body
$ npm install -D @types/body-parser @types/errorhandler @types/eslint @types/express @types/jest @types/morgan @types/node @types/shelljs @typescript-eslint/eslint-plugin @typescript-eslint/parser concurrently eslint eslint-config-prettier eslint-plugin-prettier jest nodemon prettier shelljs ts-jest ts-node typescript
```

## 各種設定ファイルの作成

`package.json`を下記の通り編集します。
主にビルドやデバッグ関連のタスクを記載します。

```json:package.json
〜省略〜
  "scripts": {
    "start": "npm run build && npm run serve",
    "build": "npm run build-ts && npm run lint",
    "serve": "node -r dotenv/config dist/server.js",
    "watch-node": "nodemon -r dotenv/config dist/server.js",
    "watch": "concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"cyan.bold,green.bold\" \"npm run watch-ts\" \"npm run watch-node\"",
    "test": "jest --setupFiles dotenv/config --detectOpenHandles --forceExit --coverage --verbose",
    "build-ts": "tsc",
    "watch-ts": "tsc -w",
    "lint": "tsc --noEmit && eslint \"**/*.{js,ts}\" --quiet --fix",
    "debug": "npm run build && npm run watch-debug",
    "serve-debug": "nodemon --inspect -r dotenv/config dist/server.js",
    "watch-debug": "concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"cyan.bold,green.bold\" \"npm run watch-ts\" \"npm run serve-debug\""
  },
〜省略〜
```

VSCodeの設定ファイルを作成します。デバッグ関連の設定を記載します。

```json:.vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "attach",
            "name": "Attach by Process ID",
            "processId": "${command:PickProcess}",
            "protocol": "inspector"
        },
        {
            "type": "node",
            "request": "launch",
            "name": "Debug Tests",
            "args": [
                "${relativeFile}"
            ],
            "runtimeArgs": [
                "--inspect-brk",
                "${workspaceRoot}/node_modules/jest/bin/jest.js",
                "--runInBand",
                "--silent=true",
                "-o"
            ],
            "console": "integratedTerminal",
            "internalConsoleOptions": "neverOpen",
            "port": 9229
        }
    ]
}
```

ESLint関連の設定を記載します。

```json:.vscode/settings.json
{
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
    },
    "eslint.validate": [
        "javascript",
        "typescript"
    ],
    "editor.formatOnSave": true,
    "[javascript]": {
        "editor.formatOnSave": false
    },
    "[typescript]": {
        "editor.formatOnSave": false
    },
    "[markdown]": {
        "editor.formatOnSave": false
    },
    "search.exclude": {
        "**/node_modules": true,
        "**/bower_components": true,
        "**/dist": true,
        "**/coverage": true
    },
    "typescript.referencesCodeLens.enabled": true
}
```

ESLintの設定ファイルを作成します。

```text:.eslintignore
# /node_modules/* in the project root is ignored by default
# build artefacts
dist/*
coverage/*
# data definition files
**/*.d.ts
# 3rd party libs
/src/public/
# custom definition files
/src/types/
```

```text:.eslintrc
{
  "parser": "@typescript-eslint/parser",
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint"
  ],
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "rules": {
    "semi": [
      "error",
      "always"
    ],
    "quotes": [
      "error",
      "double"
    ],
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/no-explicit-any": 1,
    "@typescript-eslint/no-inferrable-types": [
      "warn",
      {
        "ignoreParameters": true
      }
    ],
    "@typescript-eslint/no-unused-vars": "warn"
  }
}
```

Typescriptの設定ファイルを作成します。

```json:tsconfig.json
{
    "compilerOptions": {
        "module": "commonjs",
        "esModuleInterop": true,
        "allowSyntheticDefaultImports": true,
        "target": "es6",
        "noImplicitAny": true,
        "moduleResolution": "node",
        "resolveJsonModule": true,
        "sourceMap": true,
        "outDir": "dist",
        "baseUrl": ".",
        "paths": {
            "*": [
                "node_modules/*",
                "src/types/*"
            ]
        }
    },
    "include": [
        "src/**/*"
    ]
}
```

Jestの設定ファイルを作成します。

```javascript:jest.config.js
// eslint-disable-next-line no-undef
module.exports = {
  globals: {
    "ts-jest": {
      tsConfig: "tsconfig.json"
    }
  },
  moduleFileExtensions: ["ts", "js"],
  transform: {
    "^.+\\.(ts|tsx)$": "ts-jest"
  },
  testMatch: ["**/test/**/*.test.(ts|js)"],
  testEnvironment: "node",
  setupFiles: ["dotenv/config"]
};
```

## コードの作成と実行

作成するファイル一覧は下記の通りです。

```shell-session
line-bot-starter
├── .env                 <- LINEボットのシークレットトークンなど設定
├── .eslintignore        <- 上記で作成済み
├── .eslintrc            <- 上記で作成済み
├── .vscode
│   ├── launch.json     　<- 上記で作成済み
│   └── settings.json   　<- 上記で作成済み
├── jest.config.js        <- 上記で作成済み
├── package-lock.json     <- 上記のnpm installで作成済み
├── package.json          <- 上記で作成済み
├── src
│   ├── app.ts
│   ├── config
│   │   └── botText.json  <- LINEボットのレスポンスメッセージ定義
│   ├── controllers
│   │   └── callback.ts   <- LINEボットのwebhookコード
│   └── server.ts
├── test
│   └── callback.test.ts  <- webhookのテストコード
└── tsconfig.json         <- 上記で作成済み
```

LINEボット動作に必要なアクセストークンやシークレットトークンを`.env`に記載します。

```text:.env
CHANNEL_ACCESS_TOKEN=YOUR_CHANNEL_ACCESS_TOKEN
CHANNEL_SECRET=YOUR_CHANNEL_SECRET
```

ボット実装部分を下記の通り作成します。

```javascript:src/server.ts
import errorHandler from "errorhandler";
import app from "./app";

app.use(errorHandler());
const server = app.listen(app.get("port"), () => {
  console.log(
    "  App is running at http://localhost:%d in %s mode",
    app.get("port"),
    app.get("env")
  );
  console.log("  Press CTRL-C to stop\n");
});

export default server;
```

```javascript:src/app.ts
import { JSONParseError, SignatureValidationFailed, middleware } from "@line/bot-sdk";
import bodyParser from "body-parser";
import express from "express";
import { Request, Response, NextFunction, ErrorRequestHandler } from "express";
import morgan from "morgan";
import morganBody from "morgan-body";
import path from "path";
import * as callbackController from "./controllers/callback";

const app = express();
app.set("port", process.env.PORT || 3000);
app.use(morgan("combined"));
app.use((err: ErrorRequestHandler, req: Request, res: Response, next: NextFunction) => {
    if (err instanceof SignatureValidationFailed) {
      res.status(401).send(err.signature);
      return;
    } else if (err instanceof JSONParseError) {
      res.status(400).send(err.raw);
      return;
    }
    next(err);
  }
);
app.get("/callback", (req, res) => {
  res.end("I'm listening. Please access with POST.");
});
app.use("/callback", middleware(callbackController.config));
app.use(bodyParser.json());
morganBody(app);
app.post("/callback", callbackController.callback);

export default app;
```

```javascript:src/controllers/callback.ts
import { Client, EventSource, TextMessage, WebhookEvent, Message } from "@line/bot-sdk";
import { Request, Response } from "express";

import botText from "../config/botText.json";
type BotText = typeof botText;
type botTextKey = keyof BotText;

export const config = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
  channelSecret: process.env.CHANNEL_SECRET
};

export const client = new Client(config);

export const replyText = (token: string, texts: string | string[]) => {
  texts = Array.isArray(texts) ? texts : [texts];
  return client.replyMessage(
    token,
    texts.map(text => ({ type: "text", text }))
  );
};

export function handleText(message: TextMessage, replyToken: string, source: EventSource) {
  switch (message.text) {
    case "profile": {
      if (source.userId) {
        return client
          .getProfile(source.userId)
          .then(profile =>
            replyText(replyToken, [
              `Display name: ${profile.displayName}`,
              `Picture: ${profile.pictureUrl}`,
              `Status message: ${profile.statusMessage}`
            ])
          );
      } else {
        return replyText(
          replyToken,
          "Bot can't use profile API without user ID"
        );
      }
    }
    default: {
      const textKey = message.text;
      if (textKey in botText) {
        const msg = botText[textKey as botTextKey] as Message;
        return client.replyMessage(replyToken, msg);
      } else {
        console.log(`Echo message to ${replyToken}: ${message.text}`);
        return replyText(replyToken, message.text);
      }
    }
  }
}

export function handleEvent(event: WebhookEvent) {
  if (event.type === "message") {
    if (event.replyToken && event.replyToken.match(/^(.)\1*$/)) {
      return console.log(
        "Test hook recieved: " + JSON.stringify(event.message)
      );
    }
  }
  switch (event.type) {
    case "message": {
      const message = event.message;
      switch (message.type) {
        case "text":
          return handleText(message, event.replyToken, event.source);
        default:
          throw new Error(`Unknown message: ${JSON.stringify(message)}`);
      }
    }
    default: {
      throw new Error(`Unknown event: ${JSON.stringify(event)}`);
    }
  }
}

export const callback = (req: Request, res: Response) => {
  if (req.body.destination) {
    console.log("Destination User ID: " + req.body.destination);
  }
  if (!Array.isArray(req.body.events)) {
    return res.status(500).end();
  }
  Promise.all(req.body.events.map(handleEvent))
    .then(result => res.json(result))
    .catch(err => {
      console.error(err);
      res.status(500).end();
    });
};
```

```json:src/config/botText.json
{
  "ping": {
    "type": "text",
    "text": "pong"
  }
}
```

```javascript:test/callback.test.ts
import { client, replyText, handleText } from "../src/controllers/callback";

jest.mock("@line/bot-sdk");

describe("callback.ts test", () => {
  beforeEach(() => {
    client.replyMessage = jest
      .fn()
      .mockImplementation((token: string, texts: string | string[]) => {
        console.log(`token:${token}, texts:${JSON.stringify(texts)}`);
        return new Promise(resolve => {
          resolve({
            token: token,
            texts: JSON.stringify(texts)
          });
        });
      });
  });
  test("replyText", async () => {
    const expected = await replyText("0000000000", "Hello, World!");
    expect(expected).toEqual({
      token: "0000000000",
      texts: JSON.stringify([{ type: "text", text: "Hello, World!" }])
    });
  });
  test("handleText ping", async () => {
    const expected = await handleText(
      {
        type: "text",
        text: "ping"
      },
      "0000000000",
      {
        type: "user",
        userId: "0000000001"
      }
    );
    expect(expected).toEqual({
      token: "0000000000",
      texts: JSON.stringify({ type: "text", text: "pong" })
    });
  });
});
```

コンパイル&実行します。
`App is running at http://localhost:3000 in development mode`が表示されれば成功です。

```shell-session
$ npm run start

> line-bot@1.0.0 build /Users/xxxxx/line-bot-starter
> npm run build-ts && npm run lint

> line-bot@1.0.0 build-ts /Users/xxxxx/line-bot-starter
> tsc

> line-bot@1.0.0 lint /Users/xxxxx/line-bot-starter
> tsc --noEmit && eslint "**/*.{js,ts}" --quiet --fix

> line-bot@1.0.0 serve /Users/xxxxx/line-bot-starter
> node -r dotenv/config dist/server.js

  App is running at http://localhost:3000 in development mode
  Press CTRL-C to stop
```

テストを実行する場合は、下記のコマンドを実行します。

```shell-session
$ npm run test

> line-bot-starter@1.0.0 test /Users/xxx/line-bot-starter
> jest --setupFiles dotenv/config --detectOpenHandles --forceExit --coverage --verbose

 PASS  test/callback.test.ts
  callback.ts test
    ✓ replyText (13ms)
    ✓ handleText ping (3ms)

  console.log test/callback.test.ts:10
    token:0000000000, texts:[{"type":"text","text":"Hello, World!"}]

  console.log test/callback.test.ts:10
    token:0000000000, texts:{"type":"text","text":"pong"}

-------------|---------|----------|---------|---------|---------------------------------------------------------------------
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s                                                   
-------------|---------|----------|---------|---------|---------------------------------------------------------------------
All files    |   41.03 |    13.64 |    37.5 |   41.03 |                                                                     
 callback.ts |   41.03 |    13.64 |    37.5 |   41.03 | 36,37,40,47,59,60,67-69,74,76,77,79,81,85,91,92,94,95,97,98,100,101 
-------------|---------|----------|---------|---------|---------------------------------------------------------------------
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.302s
Ran all test suites.
```

## Glitchでの動作確認

下記のファイルをGlitchにアップすればコンパイルされ実行されます。

```shell-session
line-bot-starter
├── .env
├── .eslintignore
├── .eslintrc
├── jest.config.js
├── package.json
├── src
│   ├── app.ts
│   ├── config
│   │   └── botText.json
│   ├── controllers
│   │   └── callback.ts
│   └── server.ts
├── test
│   └── callback.test.ts
└── tsconfig.json
```

下記にGlitchプロジェクトをおいてますので参考になれば幸いです。
[<img src="https://cdn.glitch.com/2bdfb3f8-05ef-4035-a06e-2043962a3a13%2Fview-source%402x.png?1513093958802" alt="view source" height="33">](https://glitch.com/edit/?utm_content=project_glorious-fantasy-goldenrod&utm_source=view_source&utm_medium=button&utm_campaign=glitchButton#!/glorious-fantasy-goldenrod)　[<img src="https://cdn.glitch.com/2bdfb3f8-05ef-4035-a06e-2043962a3a13%2Fremix%402x.png?1513093958726" alt="remix this" height="33">](https://glitch.com/edit/?utm_content=project_glorious-fantasy-goldenrod&utm_source=remix_this&utm_medium=button&utm_campaign=glitchButton#!/remix/glorious-fantasy-goldenrod)

## 参考サイト

- [LINE Developers](https://developers.line.biz/ja/docs/messaging-api/building-sample-bot-with-heroku/)
- [line-bot-sdk-nodejs documentation](https://line.github.io/line-bot-sdk-nodejs/#getting-started)
- [TypeScript-Node-Starter](https://github.com/microsoft/TypeScript-Node-Starter)