Cloudflare Workers/D1 + OpenAI で 会話記憶機能付き LINE bot を作る
はじめに
こんにちは。しんりうです。
今回はタイトルの通り Cloudflare と OpenAI を使って会話記憶機能付きの LINE bot を作ってみます。
LINE bot は、ユーザーからメッセージを受信するとバックエンドサーバーに通知(Webhook)を送信します。そしてバックエンド側で応答メッセージを生成し、返信する仕組みになっています。
本記事ではバックエンドとして Cloudflare Workers を、会話履歴を保存するデータベースとして Cloudflare D1 を使用します。
これにより、会話の文脈を考慮した応答が可能な LINE bot をサーバーレスに実現することができます。
また、今回の実装内容は Coudflare の無料枠の範囲内で実現可能です。そのため、興味のある方はぜひ実際に手を動かして作ってみてください。
この記事で触れること
- Cloudflare Workers/D1 を使ったサーバーレスアプリケーションの作り方
- Open AI / LINE Messaging API の基本的な使い方(SDK の使用あり)
- Drizzle ORM を使った D1 データベースの操作方法
なお本記事では、各技術の基本的な概念や詳細な設定手順については説明を省略します。
必要に応じて公式ドキュメントや他の方の記事をご参照ください。
完成イメージ
会話の文脈を失わずに応答できる LINE bot を実装します。
実装
今回は以下のステップで実装します。
- LINE Messaging API のセットアップ
- 基本的な応答機能の実装
- OpenAI API との連携
- 会話履歴を元にした回答生成
動作環境については Node.js バージョンが 21.2.0
、各ライブラリのバージョンは以下のとおりです。
"wrangler": "^3.60.3"
"openai": "^4.77.0"
"@line/bot-sdk": "^9.5.0",
"drizzle-orm": "^0.38.3",
"drizzle-kit": "^0.30.1"
また、ソースコードは以下のリポジトリに公開しています。
LINE Messaging API のセットアップ
LINE 公式アカウントを チャット bot として動作させるため、まずはじめに Messaging API を有効化する必要があります。
具体的な手順は以下の LINE Developers 公式ドキュメントを参照ください。
また、以上に加えて 2 つの設定を行なってください。
- LINE Official Account Manager コンソールで応答メッセージのオフ
- LINE Developers コンソールでチャンネルアクセストークンの発行
基本的な応答機能の実装
ここから実際にコードを書いていきます。
Cloudflare Workers プロジェクトのセットアップ
Cloudflare Workers プロジェクトを新規作成します。
$ pnpm create cloudflare@latest <任意のプロジェクト名>
上記コマンドを実行すると、CLI 方式でプロジェクトの初期設定を行うことになります。
各選択肢は以下のようにしてください。
- category ... Hello World example
- type ... Hello World Worker
- lang ... Typescript
LINE bot SDK のインストール
LINE bot では SDK を使うことで 効率的に Messaging API を呼び出すことができます。
本記事ではこれを活用します。
$ pnpm add @line/bot-sdk
メッセージ応答機能の実装
公式の Basic Usage を参考にしつつ、オウム返しを実装してみます。
events.filter()
からわかるように、ユーザーがテキストメッセージを送った時のみ返信するようにしています。(つまり、画像やスタンプに対しては反応しません。)
import { messagingApi } from "@line/bot-sdk";
import type { WebhookEvent, MessageEvent, TextMessage } from "@line/bot-sdk";
interface WebhookRequestBody {
events: WebhookEvent[];
}
export default {
async fetch(request, env, ctx): Promise<Response> {
const client = new messagingApi.MessagingApiClient({
channelAccessToken: env.LINE_CHANNEL_ACCESS_TOKEN,
});
const { events } = (await request.json()) as WebhookRequestBody;
await Promise.all(
events
.filter(
(e): e is MessageEvent & { message: TextMessage } =>
e.type === "message" && e.message.type === "text"
)
.map((e) =>
client.replyMessage({
replyToken: e.replyToken,
messages: [
{
type: "text",
text: e.message.text, // 受け取ったメッセージをそのまま返す
},
],
})
)
);
return new Response("OK");
},
} satisfies ExportedHandler<Env>;
環境変数を設定します。
LINE Developers コンソールで取得した値を設定してください。
$ npx wrangler secret put LINE_CHANNEL_ACCESS_TOKEN
interface Env {
LINE_CHANNEL_ACCESS_TOKEN: string;
}
Webhook URL の設定
LINE Developers コンソール上から Webhook URL を設定します。
こちらを参照しながら、Webhook URL には Workers の デプロイ URL を設定してください。
これでオウム返しをする LINE bot ができました。
OpenAI API との連携
次に、ユーザーからのメッセージに対して ChatGPT で回答を生成し、それをユーザーに返信する機能を実装します。
OpenAI SDK のインストール
OpenAI にも公式の SDK があるため、こちらを使用します。
$ pnpm add openai
環境変数の追加
OpenAI の API キーを格納するための環境変数を追加します。
発行方法は OpenAI の 公式 docs をご参照ください
$ npx wrangler secret put OPENAI_API_KEY
Workers で読み取れるように Env
を拡張します。
interface Env {
LINE_CHANNEL_ACCESS_TOKEN: string;
+ OPENAI_API_KEY: string;
}
コードの整理とディレクトリ構成
ここで OpenAI の処理を書く前にsrc/index.ts
を複数のモジュールに分割し、責務ごとに整理します。
以下が基本的なファイル構成です。
.
├── README.md
├── package.json
├── pnpm-lock.yaml
├── src
│ ├── index.ts # エントリーポイント
│ ├── handlers
│ │ └── message.ts # Webhook ハンドラ(メッセージ受信時の処理)
│ └── services
│ ├── line.ts # LINE Messaging APIとの連携処理
│ ├── openai.ts # OpenAI APIとの連携処理
│ └── clients.ts # 各サービスクライアントの初期化
└── worker-configuration.d.ts # 環境変数の管理
以上を踏まえて各モジュールの実装内容を見ていきましょう。
services/line.ts
)
LINE 周りの処理 (まず、src/index.ts
に書いていた LINE SDK の処理を移動させます。
import { messagingApi } from "@line/bot-sdk";
// LINE クライアントを初期化
export function createLineClient(token: string) {
return new messagingApi.MessagingApiClient({ channelAccessToken: token });
}
// メッセージを返信
export async function sendReply(
client: messagingApi.MessagingApiClient,
replyToken: string,
text: string
) {
await client.replyMessage({
replyToken,
messages: [
{
type: "text",
text,
},
],
});
}
services/openai.ts
)
OpenAI 周りの処理 (ChatGPT との会話を管理します。
システムプロンプトの設定や、回答の生成を行います。
OpenAI の 公式 docs を参考にしながら、回答生成の処理を書きます。
import OpenAI from "openai";
export type ChatMessage = {
role: "system" | "user" | "assistant";
content: string;
};
// OpenAI クライアントを初期化
export function createOpenAIClient(apiKey: string) {
return new OpenAI({ apiKey });
}
// ChatGPT からの応答を生成
export async function generateResponse(openai: OpenAI, content: string) {
const model = "gpt-4o-mini-2024-07-18"; // 適宜利用可能なモデルを指定してください。
const systemSetting = `
あなたは親切なアシスタントです。
以下の制約に従って回答してください:
- マークダウン記法を使用しない
- コードブロックやインラインコードの記法を使用しない
- 箇条書きには記号や番号ではなく、「・」を使用する
- 簡潔に応答する
`;
const messages: ChatMessage[] = [
{
role: "system",
content: systemSetting,
},
{ role: "user", content },
];
const completion = await openai.chat.completions.create({
model,
messages,
});
const responseMeessage = completion.choices[0].message.content;
const errorMessage = "申し訳ありません。回答を生成できませんでした。";
return responseMeessage || errorMessage;
}
services/clients.ts
)
クライアント管理 (各サービスのクライアントを一元管理し、初期化を行います。
import type { messagingApi } from "@line/bot-sdk";
import type OpenAI from "openai";
import { createLineClient } from "./line";
import { createOpenAIClient } from "./openai";
export interface ServiceClients {
lineClient: messagingApi.MessagingApiClient;
openaiClient: OpenAI;
}
export function initializeClients(env: Env): ServiceClients {
return {
lineClient: createLineClient(env.LINE_CHANNEL_ACCESS_TOKEN),
openaiClient: createOpenAIClient(env.OPENAI_API_KEY),
};
}
handlers/message.ts
)
メッセージハンドラ (Webhook で受け取ったイベントを処理し、適切なサービスを呼び出します。
import type { WebhookEvent } from "@line/bot-sdk";
import type { MessageEvent, TextMessage } from "@line/bot-sdk";
import { generateResponse } from "../services/openai";
import { sendReply } from "../services/line";
import type { ServiceClients } from "../services/clients";
type TextMessageEvent = MessageEvent & { message: TextMessage };
// テキストメッセージイベントかどうかの判定処理
function isTextMessageEvent(event: WebhookEvent): event is TextMessageEvent {
return event.type === "message" && event.message.type === "text";
}
// Webhook イベントの処理
export async function handleEvents(events: WebhookEvent[], clients: ServiceClients) {
const textMessageEvents = events.filter(isTextMessageEvent);
return Promise.all(textMessageEvents.map((event) => handleTextMessage(event, clients)));
}
// テキストメッセージイベントの処理
async function handleTextMessage(event: TextMessageEvent, clients: ServiceClients) {
const { lineClient, openaiClient } = clients;
try {
const responseMessage = await generateResponse(openaiClient, event.message.text);
await sendReply(lineClient, event.replyToken, responseMessage);
} catch (error) {
console.error("Error handling message:", error);
await sendReply(lineClient, event.replyToken, "エラーが発生しました。");
}
}
waitUntil
API の使用 (src/index.ts
)
src/index.ts
を編集します。
ここで、LINE の Webhook は 2 秒以内を目安に 200 レスポンスを返すことが推奨されています。
そのため、今回は ctx.waitUntil()
API を使用してこの制約に対応します[1]。
ctx.waitUntil()
を使うと、レスポンスを返した後も継続して Worker ランタイムが実行されます。
つまり、今回のケースでは以下のような処理フローを実現できます。
- Webhook リクエストを受信後、即座に 200 レスポンスを返す
- Worker 環境で非同期に OpenAI による応答生成と LINE への返信を実行
これにより応答生成に時間がかかる場合でも LINE プラットフォーム側のタイムアウトを回避することができます。
import type { WebhookEvent } from "@line/bot-sdk";
import { handleEvents } from "./handlers/message";
import { initializeClients } from "./services/clients";
interface WebhookRequestBody {
events: WebhookEvent[];
}
export default {
async fetch(request, env, ctx): Promise<Response> {
const { events } = (await request.json()) as WebhookRequestBody;
const clients = initializeClients(env);
// waitUntil で Promise 関数をラップする
ctx.waitUntil(handleEvents(events, clients));
return new Response("OK");
},
} satisfies ExportedHandler<Env>;
これで OpenAI API を介して返信できるようになりました。
会話履歴を元にした回答生成
仕上げに、データベースを参照して会話履歴を元にした回答ができるようにします。
ここで DB には Cloudflare D1、ORM には Drizzle を使用します[2]。
D1 の セットアップ
まず、以下コマンドで D1 を作成します。
$ npx wrangler d1 create <任意のデータベース名>
⛅️ wrangler 3.92.0
-------------------
✅ Successfully created DB <任意のデータベース名> in region WNAM
Created your new D1 database.
[[d1_databases]]
binding = "DB"
database_name = "<任意のデータベース名>"
database_id = "xxxx-xxxx-xxxx-xxxx"
次に wrangler.toml
を次のように修正し、Worker が D1 に接続できるようにします。
[[d1_databases]]
binding = "DB"
database_name = "<任意のデータベース名>"
database_id = "xxxx-xxxx-xxxx-xxxx"
そして Env
を拡張して D1 をバインドします。
interface Env {
LINE_CHANNEL_ACCESS_TOKEN: string;
OPENAI_API_KEY: string;
+ DB: D1Database;
}
Drizzle ORM のセットアップ
パッケージをインストールします。
$ pnpm add drizzle-orm
$ pnpm add -D drizzle-kit
コンフィグファイルを作成します。
import type { Config } from "drizzle-kit";
export default {
schema: "./drizzle/schema.ts",
out: "./drizzle/migrations",
driver: "d1-http",
dialect: "sqlite",
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID as string,
databaseId: process.env.CLOUDFLARE_DATABASE_ID as string,
token: process.env.CLOUDFLARE_D1_TOKEN as string,
},
} satisfies Config;
DB スキーマを定義します。
今回は id
, userId
, role
, content
, createdAt
のカラムを持つ conversations
テーブルを作成します。
import { sql } from "drizzle-orm";
import { text, sqliteTable } from "drizzle-orm/sqlite-core";
export const conversations = sqliteTable("conversations", {
id: text("id").primaryKey().notNull(),
userId: text("user_id").notNull(),
role: text("role").notNull(),
content: text("content").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
マイグレーションファイルを生成し、マイグレーションを実行します
これにより D1 にテーブルが作成されます。
$ npx drizzle-kit generate --config ./drizzle/drizzle.config.ts
$ npx wrangler d1 execute <任意のデータベース名> --file ./drizzle/migrations/<任意のマイグレーションファイル> --remote
メッセージの保存・会話履歴の取得関数の実装
services レイヤに db.ts
を新規作成し、メッセージの保存処理および会話履歴の取得処理を定義します。
会話履歴の取得処理では、最近のメッセージを 10 件取得することにします。
import { drizzle } from "drizzle-orm/d1";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { conversations } from "../../drizzle/schema";
import { desc, eq } from "drizzle-orm";
// DB クライアントの初期化
export function createDBClient(db: D1Database) {
return drizzle(db);
}
// メッセージの保存
export async function saveMessage(
db: DrizzleD1Database,
userId: string,
content: string,
role: "user" | "assistant"
) {
return await db.insert(conversations).values({
id: crypto.randomUUID(),
userId,
content,
role,
});
}
// 会話履歴の取得
export async function getConversationHistory(db: DrizzleD1Database, userId: string) {
const limit = 10;
const messages = await db
.select()
.from(conversations)
.where(eq(conversations.userId, userId))
.orderBy(desc(conversations.createdAt))
.limit(limit);
// 会話履歴を時系列順(昇順)で返す
return messages.reverse();
}
import type { messagingApi } from "@line/bot-sdk";
import type OpenAI from "openai";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import { createLineClient } from "./line";
import { createOpenAIClient } from "./openai";
+ import { createDBClient } from "./db";
export interface ServiceClients {
lineClient: messagingApi.MessagingApiClient;
openaiClient: OpenAI;
+ dbClient: DrizzleD1Database;
}
export function initializeClients(env: Env): ServiceClients {
return {
lineClient: createLineClient(env.LINE_CHANNEL_ACCESS_TOKEN),
openaiClient: createOpenAIClient(env.OPENAI_API_KEY),
+ dbClient: createDBClient(env.DB),
};
}
回答生成時に会話履歴を渡す
src/services/openai.ts
を修正し、会話履歴も渡してあげるようにします。
- export async function generateResponse(openai: OpenAI, content: string) {
+ export async function generateResponse(openai: OpenAI, content: string, history: ChatMessage[]) {
...
省略
...
const messages: ChatMessage[] = [
{
role: "system",
content: systemSetting,
},
+ ...history, // 会話履歴を messages に追加
{
role: "user",
content,
},
];
...
省略
...
ハンドラーの修正
最後にハンドラーを修正し、会話履歴の取得、ユーザーのメッセージ・ChatGPT による回答の保存を行うようにします。
import type { WebhookEvent } from "@line/bot-sdk";
import type { MessageEvent, TextMessage } from "@line/bot-sdk";
import { generateResponse } from "../services/openai";
+ import type { ChatMessage } from "../services/openai";
import { sendReply } from "../services/line";
import type { ServiceClients } from "../services/clients";
+ import { getConversationHistory, saveMessage } from "../services/db";
...
省略
...
async function handleTextMessage(event: TextMessageEvent, clients: ServiceClients) {
- const { lineClient, openaiClient } = clients;
+ const { lineClient, openaiClient, dbClient } = clients;
const userId = event.source.userId;
if (!userId) return;
try {
// 会話履歴の取得
+ const hisotry = await getConversationHistory(dbClient, userId);
+ const messageHistory: ChatMessage[] = hisotry.map((msg) => ({
+ role: msg.role as "user" | "assistant",
+ content: msg.content,
+ }));
// ChatGPT の回答生成時に会話履歴を渡す
- const responseMessage = await generateResponse(openaiClient, event.message.text);
+ const responseMessage = await generateResponse(
+ openaiClient,
+ event.message.text,
+ messageHistory
+ );
// DBへユーザーのメッセージ・ChatGPT による回答を保存
+ if (!responseMessage) throw new Error("Failed to generate response");
+ await saveMessage(dbClient, userId, event.message.text, "user");
+ await saveMessage(dbClient, userId, responseMessage, "assistant");
await sendReply(lineClient, event.replyToken, responseMessage);
} catch (error) {
console.error("Error handling message:", error);
await sendReply(lineClient, event.replyToken, "エラーが発生しました。");
}
}
動作確認
再度デプロイすると、ChatGPT が会話の文脈を考慮しながら返信できていることが確認できます。
また、D1 データベースの中身を確認してみると、会話履歴が正しく保存されていることがわかります。
おわりに
今回は Cloudflare Workers/D1 + OpenAI による 会話記憶機能付き LINE bot をご紹介しました。
どなたかの参考になれば幸いです。
また、DeNA 25 新卒 Advent Calendar 2024 の他の記事もぜひご覧ください。
ここまで読んでいただきありがとうございました。
-
代わりに Cloudflare Queues で対応することもできるそうです。ただしこちらの方法は別途料金がかかる上実装も複雑になるため、今回は採用しませんでした。 ↩︎
-
筆者は ngrok を使用して開発を進めました。 ↩︎
Discussion