Cloudflare で実現する実用的な LINE BOT のアーキテクチャ

# Cloudflare で実現する実用的な LINE BOT のアーキテクチャ

## はじめに

本記事では、Cloudflareの各種サービスを活用して、実用的なLINE BOTを構築する方法を詳しく解説します。

## Cloudflareを使った実装

まずは基本的な実装から始めて、徐々に機能を追加していく形で解説します。

### 0. プロジェクトの作成

まずは以下のコマンドを実行します。

```
npm create cloudflare@latest
```

途中、いくつか入力を求められますが、

- dir: デフォルトのまま
- category : Hello World example
- type : Hello World Worker
- lang : TypeScript
- git : No
- deploy : No

を指定します。
完了するとフォルダが一つできていますので、移動しておきます。

動作確認は以下のコマンドで行います。

```
npx wrangler dev
```

正しく環境構築できていればローカルサーバーでアプリケーションが起動し、 http://localhost:8787 にアクセスすると「Hello, World!」と表示されるはずです。

最後に、デプロイするためには以下のコマンドを実行します。

```
npx wrangler deploy
```


### 1. シンプルなエコーボット

最も基本的な実装として、ユーザーが送信したメッセージをそのまま返信するエコーボットを作成します。

本記事では、ボット用の Messaging API チャネルを用意している前提で進めます。
Messaging API チャネルの用意がない方は、公式ドキュメントの「[Messaging APIを始めよう](https://developers.line.biz/ja/docs/messaging-api/getting-started/)」のページを参考に作成してください。

まずは、先ほどデプロイした Cloudflare Workers の URL を Webhook URL に登録します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314315/19a1b4a4-517e-611f-90c4-ef35fd350698.png)

この時点でも「検証」ボタンをクリックすると成功するはずです。
ここから、エコーボットとして応答するように Worker に修正を加えていきます。

まずは、エコーボットに必要な環境変数を登録します。
以下のコマンドを実行し、

```
npx wrangler secret put LINE_CHANNEL_ACCESS_TOKEN
```

Secrets の入力を求められるのでチャネルアクセストークンを入力します。
そして、この環境変数を使用するために `worker-configuration.d.ts` を以下のように修正します。

```typescript:worker-configuration.d.ts
interface Env {
	LINE_CHANNEL_ACCESS_TOKEN: string;
}
```


続いて、LINE Bot SDK のインストールです。

```
npm install @line/bot-sdk
```

最後に、ソースコードを修正します。

```typescript:src/index.ts
import * as line from '@line/bot-sdk';

export default {
	async fetch(request, env, ctx): Promise<Response> {
		// 環境変数の取得
		const LINE_CHANNEL_ACCESS_TOKEN = env.LINE_CHANNEL_ACCESS_TOKEN;

		// LINE Messaging APIクライアントの生成
		const client = new line.messagingApi.MessagingApiClient({ channelAccessToken: LINE_CHANNEL_ACCESS_TOKEN });

		// Webhookイベントの解析
		const body = await request.text();
		const events = JSON.parse(body).events;

		for (const event of events) {
			if (event.type === 'message' && event.message.type === 'text') {
				// テキストメッセージの場合、同じ内容をオウム返し
				await client.replyMessage({
					replyToken: event.replyToken,
					messages: [
						{
							type: 'text',
							text: event.message.text,
						},
					],
				});
			}
		}

		return new Response('OK');
	},
} satisfies ExportedHandler<Env>;
```

このコードをデプロイすることで、基本的なエコーボットが完成します。

### 2. メディアの取り扱い

続いて、このボットが画像や動画を含むメッセージを送信できるようにします。
LINE BOTでメディアを送信する場合、LINE Messaging APIの仕様上、メディアファイルは公開されたURLから取得可能である必要があります。Cloudflare R2を使用してこれを実現します。

R2でメディアを公開する方法は主に3つあります。

1. r2.devサブドメイン（開発用）
2. カスタムドメイン（本番用）
3. Cloudflare Workersを経由

r2.dev サブドメインはキャッシュされずパフォーマンスも低いので、本番環境では通常カスタムドメインを使用することをお勧めします。
ただ、カスタムドメインを使うためにはドメインの準備が必要になって少し手間があるので、ここではお手軽にパフォーマンスを出すための第３の選択肢として Cloudflare Workers を経由する方法を紹介します。

まずは、コンソール上で R2 のバケットを作成します。
ここでは、`my-bucket` という名前でバケットを作成したことにします。

次に、Workers からバケットにアクセスできるよう、バインディングします。

```toml:wrangler.toml
[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-bucket"
```

ソースコードからも使えるようにします。

```typescript:worker-configuration.d.ts
interface Env {
	LINE_CHANNEL_ACCESS_TOKEN: string;
	MY_BUCKET: R2Bucket;
}
```

最後に、`/images/` というパスでリクエストを受けた場合には R2 から画像を取得して応答するよう `fetch` 関数の処理を修正します。

```typescript:src/index.ts
export default {
	async fetch(request, env): Promise<Response> {
		// 画像の表示
		const url = new URL(request.url);
		if (url.pathname.startsWith("/images/")) {
			const object = await env.MY_BUCKET.get(
				url.pathname.replace("/images", "").slice(1)
			);
			if (object === null) {
				return new Response("Object Not Found", { status: 404 });
			}

			const headers = new Headers();
			object.writeHttpMetadata(headers);
			headers.set("etag", object.httpEtag);

			return new Response(object.body, {
				headers,
			});
		}

		// ... 以降のボットの処理 ...

		return new Response('OK');
}
```

この実装により、例えばバケット内の `test.png` という画像ファイルには https://xxxxx.workers.dev/images/test.png のような URL でアクセスできるようになります。


### 3. 非同期処理の実装

ここまでの内容で基本的な LINE BOT を作ることは出来ますが、本格的な LINE BOT を開発するためには非同期で処理する必要があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/314315/cdcf01da-da08-8cb9-e69e-36f4cd2c2dc1.png)

Cloudflare Workers でも Queues という機能を使うことで非同期処理を実現することが可能です。

:::note warn
注意
この先の内容は Paid plans の機能を使用します。無料プランではお試しすることは出来ません。
:::

まずは、Queues を作成します。以下のコマンドで、`my-queue` という名前のキューが作成されます。

```
npx wrangler queues create my-queue
```

続いて、このキューをバインディングします。キューをバインディングする際には、キューにデータを登録する「プロデューサー」とキューからデータを引き抜く「コンシューマー」の両方を設定します。

```toml:wrangler.toml
[[queues.producers]]
binding = "MY_QUEUE"
queue = "my-queue"

[[queues.consumers]]
queue = "my-queue"
```

このキューをソースコードから参照できるようにもしておきます。

```typescript:worker-configuration.d.ts
interface Env {
	LINE_CHANNEL_ACCESS_TOKEN: string;
	MY_BUCKET: R2Bucket;
	MY_QUEUE: Queue<any>;
}
```

これで準備は完了です。
ここからソースコードを修正するのですが、キューを挟んだ非同期処理になるので、動作のイメージは以下のようになります。

```mermaid
sequenceDiagram
    participant LINE
    participant Producer as Cloudflare Worker<br>（プロデューサー）
    participant Queue as Cloudflare Queues
    participant Consumer as Cloudflare Worker<br>（コンシューマー）

    %% Webhook handling
    LINE->>+Producer: Webhook イベント
    Producer->>Queue: Webhook オブジェクトを送信
    Producer-->>-LINE: 200 OK

    %% Queue processing
    Queue->>Consumer: 複数メッセージをバッチ送信
    activate Consumer
    Consumer->>Consumer: Webhook イベントの解析
    
    alt テキストメッセージ
        Consumer->>LINE: 同じ内容をオウム返しする
    end
    deactivate Consumer
```

まずはプロデューサーから修正していきます。

```typescript:src/index.ts
async fetch(request, env, ctx): Promise<Response> {
	// 画像の表示
	const url = new URL(request.url);
	if (url.pathname.startsWith('/images/')) {
		const object = await env.MY_BUCKET.get(url.pathname.replace('/images', '').slice(1));
		if (object === null) {
			return new Response('Object Not Found', { status: 404 });
		}

		const headers = new Headers();
		object.writeHttpMetadata(headers);
		headers.set('etag', object.httpEtag);

		return new Response(object.body, {
			headers,
		});
	}

	// Webhook の内容をキューに送信
	const body = await request.text();
	await env.MY_QUEUE.send(body);

	return new Response('OK');
},
```

先ほどまで、Webhook イベントを解析していた部分を、Queue へ送信する処理に修正しました。
（あわせて、環境変数の定義や LINE Bot Client の作成処理は不要になったので削除しています。）

最後に、この Queue からのメッセージを受け取る処理を追加します。

```typescript:src/index.ts
async queue(batch, env): Promise<void> {
	// 環境変数の取得
	const LINE_CHANNEL_ACCESS_TOKEN = env.LINE_CHANNEL_ACCESS_TOKEN;

	// LINE Messaging APIクライアントの生成
	const client = new line.messagingApi.MessagingApiClient({ channelAccessToken: LINE_CHANNEL_ACCESS_TOKEN });

	for (const message of batch.messages) {
		// Webhookイベントの解析
		const events = JSON.parse(message.body as string).events;
		for (const event of events) {
			if (event.type === 'message' && event.message.type === 'text') {
				// テキストメッセージの場合、同じ内容をオウム返し
				await client.replyMessage({
					replyToken: event.replyToken,
					messages: [
						{
							type: 'text',
							text: event.message.text,
						},
					],
				});
			}
		}
	}
 },
```

エコーボットの処理を移植した形になります。
このコードをデプロイすることで、非同期処理に対応したエコーボットが完成します。これにより、例えば生成 AI のような重い処理を含む LINE BOT でも正常に処理を完了させることができるようになりました。


## まとめ

今回は基本的なエコーボット実装から Queus 用いた実用的な構成まで、具体的なコードとともに解説しました。
この記事が皆さんのLINE BOT開発の参考になれば幸いです。

## 参考リンク

- [LINE Messaging API リファレンス](https://developers.line.biz/ja/reference/messaging-api/)
- [Cloudflare Workers ドキュメント](https://developers.cloudflare.com/workers/)
- [Cloudflare R2 ドキュメント](https://developers.cloudflare.com/r2/)
- [Cloudflare Queues ドキュメント](https://developers.cloudflare.com/queues/)