LINE BotをNode.jsで外部依存モジュールを使用せずに作ってみる -- 2024年1月版


Node.jsでLINE Botを作る際に普段は[@line/bot-sdk](https://www.npmjs.com/package/@line/bot-sdk)を使っていますが、外部のモジュールを使わずに書いてみます。

[6年前に書いていた記事](https://qiita.com/n0bisuke/items/56d7ace2193fbc106639)を自分で見ていて色々書き換えられそうな点が多かったので勉強がてら書き換えます。

## 環境

- Node.js v21.2.0

## 先にオウム返しBotの完成コード - コピペ用

ベースとしてLINE Botの作り方みたいな話はこちらの記事でやってる通りです。

https://qiita.com/n0bisuke/items/ceaa09ef8898bee8369d

今回は外部モジュールに依存していないので`npm i`は必要ないです。
通常だと`express`と`@line/bot-sdk`を使うのが一般的です。

オウム返しでテキストを簡単に返す部分だけになります。

```js

const crypto = require('node:crypto');
const http = require('node:http');

const HOST = 'api.line.me'; 
const REPLY_PATH = '/v2/bot/message/reply';//リプライ用
const CH_SECRET = process.env.CH_SECRET; //Channel Secretを指定
const CH_ACCESS_TOKEN = process.env.CH_ACCESS_TOKEN; //Channel Access Tokenを指定
const SIGNATURE = crypto.createHmac('SHA256', CH_SECRET);
const PORT = 3000;

// Create a local server to receive data from
const server = http.createServer();

/**
 * httpリクエスト部分 Fetch APIを使用
 */
const httpClient = async (replyToken, SendMessageObject) => {
    try {
        const REPLY_API_ENDPOINT = `https://${HOST}${REPLY_PATH}`;
        const postDataStr = JSON.stringify({ replyToken: replyToken, messages: SendMessageObject });
        const options = {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json; charset=UTF-8',
                'x-line-signature': SIGNATURE,
                'Authorization': `Bearer ${CH_ACCESS_TOKEN}`,
                'Content-Length': Buffer.byteLength(postDataStr) //長さチェック用
            },
            body: postDataStr
        };

        return fetch(REPLY_API_ENDPOINT, options);
    } catch (error) {
        throw new Error(error);
    };
};

// 署名チェック
const signatureValidation = async (xLineSignature, channelSecret, body) => {
    // Create a HMAC-SHA256 hash of the body using the channel secret as the key
    const originSignature = crypto.createHmac('SHA256', channelSecret).update(body).digest("base64");
    if(xLineSignature === originSignature){
        return true; //正常
    }else{
        return false; //異常
    }
}

const handleEvent = async (event) => {
    //メッセージが送られて来た場合
    if(event.type !== 'message' || event.message.type !== 'text'){
        console.log('TEXTメッセージではないので無視');
        return;
    }

    const SendMessageObject = [{
        type: 'text',
        quoteToken: event.message.quoteToken, //引用リプライ
        text: event.message.text
    }];

    const response = await httpClient(event.replyToken, SendMessageObject);
    const data = await response.json();
    return data;
}



// Listen to the request event
server.on('request', (req, res) => {
    const { headers, method, url } = req;
    
    res.statusCode = 200;
    res.setHeader('Content-Type', 'application/json');

    if(url !== '/' || method !== 'POST'){
        res.end();
        console.log('URLが正しくないか、POSTメソッドではありません。');
        return;
    }

    let body = '';
    req.on('data', chunk =>  body += chunk);
    req.on('end', async () => {
        
        //レスポンス用 - イベント処理を非同期化してレスポンスを早く返す
        res.end();

        //WebhookEventObjectの処理
        try {
            if(body === ''){
                console.log('bodyが空です。');
                return;
            }

            if(!signatureValidation(headers['x-line-signature'],CH_SECRET, body)){
                console.log('署名認証エラー');
                return;
            }

            if(JSON.parse(body) && JSON.parse(body).events.length < 1){
                console.log('おそらくLINE Developersからの検証イベント');
                return;
            }
            
            //Webhookの処理
            const events = JSON.parse(body).events;
            const responses = await Promise.all(events.map(handleEvent));
            console.log(responses, responses[0]);
            
            return;
        } catch (error) {
            console.log(error);
            return;
        }

    });

    req.on('error', err => console.error(err));
});

server.listen(PORT);
```

`.env`ファイルにシークレットとアクセストークンを記載しておきます。

```.env
CH_SECRET=XXXXXXXXXXXXXX
CH_ACCESS_TOKEN=YYYYYYYYYYYYYYY
```

実行

```bash
$ node --env-file=.env server.js
```

これでオウム返しのLINE Botができます。

PaaSなどどこに載せるかによって挙動が怪しい場合があるかもしれないけど検証は特にしていないです。

## 昔書いたところとの比較

### Node.jsやJavaScriptのアップデート

昔書いた記事はNode.js `v7.0.0`でした。
6年前に書いた記事で`v7.0.0`だったんですね。6年でv7からv21まで上がっているNode.js。

https://qiita.com/n0bisuke/items/56d7ace2193fbc106639

#### ■ v20.6.0から.envファイルをdotenv使わずにできるようになったので変更

以前は環境変数を外部ファイルから読み込むときにdotenvという外部モジュールを使うのが主流でしたが今は`--env-file`フラグを使えば直接使えるので起動方法を変更しました。

https://qiita.com/n0bisuke/items/c9f8cc3b7ddd419fcf1e

#### ■ v17.9.1からコアモジュールのimport/requireには`node:`を付けられるようになったので変更

ここは変更しなくても良いですが、最近は明示的にコアモジュールには`node:`を付けている傾向があるので`node:`を付けました。

https://yosuke-furukawa.hatenablog.com/entry/2021/12/27/003424

#### ■ Fetchがデフォルトで使えるようになったので、HTTPリクエストにFetchを利用するように変更

Node.jsのv18から外部モジュール依存無しでFetch APIが使えるようになっているのでHTTPリクエストはFetchに変えています。

https://github.com/nodejs/node/pull/41811

#### ■ async/awaitがv7.6.0から

昔この内容をやったときはv7.0だったみたいでPromiseを使ってましたが今回はasync/awaitで書くようにしています。

https://nodejs.org/en/blog/release/v7.6.0/

#### ■ 微修正検討

POSTデータの受け取り方をNode.jsの公式ドキュメントの書き方に習うようにChunkデータを配列でPushしていく式にしようかと思ったけど、書いてる場所によってstring形式にしている部分もあったのでそのまま。

https://nodejs.org/en/guides/anatomy-of-an-http-transaction

https://nodejs.org/api/stream.html#readablepushchunk-encoding

### LINE API周りの仕様に対しての変更

#### ■ Webhookの中身を扱う前の署名チェックを更新

著名チェックを先にした方が良いという話題があり、昔とりあえず書いたコードだと署名チェックがちゃんと出来ていなかったのでチェックをいれました。前のコード、検証ちゃんと出来てなさそう。

```js
// Signature validation
const signatureValidation = async (xLineSignature, channelSecret, body) => {
    // Create a HMAC-SHA256 hash of the body using the channel secret as the key
    const originSignature = crypto.createHmac('SHA256', channelSecret).update(body).digest("base64");
    if(xLineSignature === originSignature){
        return true; //正常
    }else{
        return false; //異常
    }
}
```

> ![スクリーンショット 2024-01-08 1.54.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/ecbeac44-b53e-bb8e-d816-4ba0792bd375.png "スクリーンショット 2024-01-08 1.54.01.png")
> https://developers.line.biz/ja/docs/messaging-api/receiving-messages/#verifying-signatures

#### ■ x-line-signatureは大文字から小文字に

今回初めて知りましたが、X-Line-Signatureだとダメみたいですね。

https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
https://developers.line.biz/ja/reference/messaging-api/#request-headers

> ![スクリーンショット 2024-01-08 1.35.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/5f7c1f62-5c7c-e274-287e-ee8652b8fa50.png "スクリーンショット 2024-01-08 1.35.17.png")

#### ■ 先にレスポンスを返すように変更

リプライを返すときにHTTPリクエストをしてからレスポンスを返すようにしてましたが、非同期化を推奨と言われたので、まず先にレスポンスを返しておくように変更しました。

> ![スクリーンショット 2024-01-08 1.48.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/c135cd6c-2964-22d5-4645-278846433ae4.png "スクリーンショット 2024-01-08 1.48.05.png")

- ざっくり従来

```js
req.on('end', async () => {
	//HTTPリクエスト
	const response = await httpClient();
    
    //イベント処理を非同期化してレスポンス
    res.statusCode = 200;
    res.setHeader('Content-Type', 'application/json');
    res.end();
}
```

- 修正後

```js
req.on('end', async () => {
    
    //レスポンス用 - イベント処理を非同期化してレスポンスを早く返す
    res.statusCode = 200;
    res.setHeader('Content-Type', 'application/json');
    res.end();
    
    //あとにHTTPリクエスト処理
    const response = await httpClient();
}
```

#### ■ 引用ツイートを返すように変更

6年前はLINE自体に引用ツイートの機能がなかったかもしれないですね。今回は引用リツイートができるようになっているので仕込んでみました。

- 変更前オウム返し

```js
const SendMessageObject = [{
    type: 'text',
    text: WebhookEventObject.message.text
}];
```

> ![スクリーンショット 2024-01-08 2.05.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/15b0156a-c2c8-0d52-9376-5e27a7cfb8fd.png "スクリーンショット 2024-01-08 2.05.14.png")

- 変更後オウム返し

```js
const SendMessageObject = [{
    type: 'text',
    quoteToken: WebhookEventObject.message.quoteToken, //引用リプライ
    text: WebhookEventObject.message.text
}];
```

> ![スクリーンショット 2024-01-08 2.05.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/ce12c694-903b-a42f-7c63-b2fe6832a0b6.png "スクリーンショット 2024-01-08 2.05.29.png")

https://developers.line.biz/ja/docs/messaging-api/get-quote-tokens/#get-quote-tokens-via-webhook

#### ■ 1つのWebhookに複数のWebhookイベントオブジェクトが含まれる場合の処理

`複数のWebhookイベントオブジェクトを含むWebhookを受信した場合も、ボットサーバーは適切な処理を行えるようにしてください`と書いてあるので、 [@line/bot-sdkの書き方](https://github.com/line/line-bot-sdk-nodejs/blob/master/examples/echo-bot/index.js#L24-L31)を真似してみました。

> ![スクリーンショット 2024-01-08 3.14.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/69d22280-4fcd-9247-24a0-e8febb033707.png "スクリーンショット 2024-01-08 3.14.56.png")

- handleEvent関数に切り分け
 
```js
const handleEvent = async (event) => {
    //メッセージが送られて来た場合
    if(event.type !== 'message' || event.message.type !== 'text'){
        console.log('TEXTメッセージではないので無視');
        return;
    }

    const SendMessageObject = [{
        type: 'text',
        quoteToken: event.message.quoteToken, //引用リプライ
        text: event.message.text
    }];

    const response = await httpClient(event.replyToken, SendMessageObject);
    const data = await response.json();
    return data;
}
```

- 呼び出し部分

```js
//省略
const events = JSON.parse(body).events;
const responses = await Promise.all(events.map(handleEvent));
console.log(responses, responses[0]);
```

## まとめ

色々変更点ありますね。まだチェックしきれてない部分もありそうですが、テキストを簡単に返す部分だけ試してますが、絵文字とか画像とかメッセージングAPI周りだけでも色々チェックした方が良い部分はありますね。SDKは偉大だ......

API仕様もアップデートがあるということで、SDK使うだけやってると変更に気づかなかったりするのでたまに仕様を見ると変わってる点があるので大事ですね。

### .env蛇足

ちなみに`.env`にコメント入れても読み取れました。知らなかった。ただ他の環境から読んだときに動かないと思うのであまりやらない方が良いのかなと思います。

```.env
CH_SECRET='XXXXXXXXXXXXXX'; //Channel Secretを指定
CH_ACCESS_TOKEN='YYYYYYYYYYYYYYY'; //Channel Access Tokenを指定
```