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


これらの記事の続きです。

- [LINE BotをBunで外部依存モジュールを使用せずに作ってみる](https://qiita.com/n0bisuke/items/d30449cd48bea3f59d1c)
- [LINE BotをNode.jsで外部依存モジュールを使用せずに作ってみる](https://qiita.com/n0bisuke/items/0ab7b3b38557384370ed)

Cloudflare WorkersでLINE Botを作ってみます。

## BunやNode.js版との違い

WebのスタンダードAPIを利用するのが基本となり、Node.jsやBun依存のメソッドは利用できないと思ったほうがよさそうです。

設定ファイルの`wrangler.toml`に`compatibility_flags = [ "nodejs_compat" ]`を記述すればNode.jsのAPI使えるという話なのですが、僕が試した限りだと関連するところだとBufferは動いたんですけど、Cryptoモジュールがうまく動いてくれなかったです。

https://developers.cloudflare.com/workers/runtime-apis/nodejs/

https://developers.cloudflare.com/workers/runtime-apis/nodejs/crypto/

https://developers.cloudflare.com/workers/runtime-apis/nodejs/buffer/


> service core:user:linebottest: Uncaught SyntaxError: The requested module 'node:crypto' does not provide an export named 'crypto'

### Content-Lengthのバイト数チェック

- Node.js版 

```js
'Content-Length': Buffer.byteLength(postDataStr)
```

- Cloudflare Workersでの場合

```js
'Content-Length': new TextEncoder().encode(postDataStr).length //長さチェック用
```

### SHA256暗号化周り

この辺はNode.jsのcryptoモジュールをCloudflare Workersでも使えそうな表記があったのですが、実際に試すと対応してないっぽいエラーがでたのでとりあえず、[Web Cripto API](https://developer.mozilla.org/ja/docs/Web/API/Crypto)を利用してみます。

- Node.js版

```js
const CH_SECRET = `シークレット`;

const SIGNATURE = crypto.createHmac('SHA256', CH_SECRET);
```

- Cloudflare Workers版

```js
const CH_SECRET = `シークレット`;

async function createHmacKey(secret) {
    const encoder = new TextEncoder();
    return await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
}
const SIGNATURE = createHmacKey(CH_SECRET);
```

### HTTPリクエストの受け取り周り

こうみると、Node.js版が特殊でBunのネイティブAPIはWebのスタンダードに合わせてそうですね。

https://developer.mozilla.org/ja/docs/Web/API/FetchEvent


- Node.js版

```js
server.on('request', (req, res) => {
    let body = '';
    req.on('data', chunk =>  body += chunk);
    req.on('end', async () => {
        	//HTTPリクエストを受け取った時の処理
        	
        	const events = JSON.parse(body).events; //Webhook Bodyの処理
    });
});
```

- Bun版

```js
Bun.serve({
    async fetch(req) {
    	//HTTPリクエストを受け取った時の処理
    	
    	const bodyStr = await req.text(); //Webhook Bodyの処理
    }
})
```

- Cloudflare Workers版

```js
export default {
    async fetch(request, env, ctx) {
    	//HTTPリクエストを受け取った時の処理
    	
    	const bodyStr = await request.text(); //Webhook Bodyの処理
    }
}
```

## 動くコードと実装

### 環境

- wrangler: v3.22.3

### コピペ用コード

```js
const HOST = 'api.line.me'; 
const REPLY_PATH = '/v2/bot/message/reply';//リプライ用
const REPLY_API_ENDPOINT = `https://${HOST}${REPLY_PATH}`;
        
/**
 * httpリクエスト部分 Fetch APIを使用
 */
const httpClient = async (replyToken, SendMessageObject, env) => {
    try {
        const SIGNATURE = crypto.subtle.importKey('raw', new TextEncoder().encode(env.CH_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);

        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 ${env.CH_ACCESS_TOKEN}`,
                'Content-Length': new TextEncoder().encode(postDataStr).length //長さチェック用
            },
            body: postDataStr
        };

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

// 署名チェック
async function signatureValidation(xLineSignature, channelSecret, body) {
    const encoder = new TextEncoder();
    const data = encoder.encode(body);
    const key = await crypto.subtle.importKey(
        'raw',
        encoder.encode(channelSecret),
        { name: 'HMAC', hash: 'SHA-256' },
        false,
        ['sign']
    );
    const signature = await crypto.subtle.sign('HMAC', key, data);
    const base64Signature = btoa(String.fromCharCode(...new Uint8Array(signature)));

    return xLineSignature === base64Signature;
}

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

    const SendMessageObject = [{
        type: 'text',
        quoteToken: event.message.quoteToken, //引用リプライ
        text: event.message.text
    }];
    try {
        console.log('repTOken:', event.replyToken);
        const response = await httpClient(event.replyToken, SendMessageObject, env);
        const data = await response.json();
        return data;
    } catch (error) {
        throw new Error(error);
    }
}

export default {
    async fetch(request, env, ctx) {
        if (request.method !== 'POST') {
            return new Response('Only POST methods are allowed', { status: 405 });
        }
    
        const url = new URL(request.url);
        if (url.pathname !== '/') {
            return new Response('Not found', { status: 404 });
        }
    
        const bodyStr = await request.text();
        const headers = request.headers;
    
        if (!await signatureValidation(headers.get('x-line-signature'), env.CH_SECRET, bodyStr)) {
            return new Response('Invalid signature', { status: 401 });
        }
    
        let events;
        try {
            const parsedBody = JSON.parse(bodyStr);
            events = parsedBody.events;
            if (events.length < 1) {
                console.log('No events received');
                return new Response('No events', { status: 200 });
            }
        } catch (error) {
            console.error('Error parsing body:', error);
            return new Response('Bad request', { status: 400 });
        }
    
        console.log('body', body);
    
        const responses = await Promise.all(events.map(event => handleEvent(event, env, ctx)));
        return new Response(JSON.stringify(responses), { headers: { 'Content-Type': 'application/json' } });
    },
}
```

`.dev.vars`のファイルに環境変数を書いて読み込めます。

```.dev.vars
CH_SECRET=YYYYYYY
CH_ACCESS_TOKEN=XXXXXXXXXXX
```

```bash
$ wrangler dev index.js
```

## LINE Bot作ってみた

消費税の計算LINE BotをNode.js+IBM Cloudで作ってたものを載せ替えてみました。

https://protopedia.net/prototype/2294

立ち上がりのコールドスタート感がなくて良き

## よもやま: Cloudflare WorkersでLINE Bot作るならHonoがよさそう

勉強がてら書いてみましたが、Cloudflare Workersで動作させるLINE Botを作る時には[Hono](https://github.com/honojs/hono)を使ったほうが書き心地よさそうです。

元々のLINE Botがexpress依存のライブラリになっているため、expressの書き味で書けるHonoは入りやすいのかなと思います。

SDKのline.Middlewareの部分がExpressのMiddlewareになっているためそのままだとHonoでは利用できず署名チェックなどは自前実装が必要になりそうでした。HTTPリクエスト部分もaxios依存っぽい感じだったので使えないかもですね。