はじめに
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
を下記の通り編集します。
主にビルドやデバッグ関連のタスクを記載します。
〜省略〜
"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の設定ファイルを作成します。デバッグ関連の設定を記載します。
{
"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関連の設定を記載します。
{
"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の設定ファイルを作成します。
# /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/
{
"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の設定ファイルを作成します。
{
"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の設定ファイルを作成します。
// 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
に記載します。
CHANNEL_ACCESS_TOKEN=YOUR_CHANNEL_ACCESS_TOKEN
CHANNEL_SECRET=YOUR_CHANNEL_SECRET
ボット実装部分を下記の通り作成します。
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;
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;
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();
});
};
{
"ping": {
"type": "text",
"text": "pong"
}
}
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プロジェクトをおいてますので参考になれば幸いです。