LoginSignup
0
1

More than 3 years have passed since last update.

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

Posted at

はじめに

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

開発環境

  • VSCode
  • Node.js
    • @line/bot-sdk
    • Typescript
    • ESLint
    • Jest
    • etc...
  • Glitch

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

プロジェクトの作成

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

$ mkdir line-bot-starter
$ cd line-bot-starter
$ npm init -y

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

$ 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を下記の通り編集します。
主にビルドやデバッグ関連のタスクを記載します。

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の設定ファイルを作成します。デバッグ関連の設定を記載します。

.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関連の設定を記載します。

.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の設定ファイルを作成します。

.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/
.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の設定ファイルを作成します。

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の設定ファイルを作成します。

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"]
};

コードの作成と実行

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

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に記載します。

.env
CHANNEL_ACCESS_TOKEN=YOUR_CHANNEL_ACCESS_TOKEN
CHANNEL_SECRET=YOUR_CHANNEL_SECRET

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

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;
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;
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();
    });
};
src/config/botText.json
{
  "ping": {
    "type": "text",
    "text": "pong"
  }
}
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が表示されれば成功です。

$ 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

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

$ 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にアップすればコンパイルされ実行されます。

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プロジェクトをおいてますので参考になれば幸いです。
view source remix this

参考サイト

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1