🔥

Cloudflare Worker + D1 + Hono + OpenAIでLINE Botを作る

2022/12/01に公開1

CloudflareのD1がAlpha版として使えるようになったしWorkerHonoの練習も兼ねて何か作ってみたい、ということで今回はLINE Botを作ってみることにする。

まず最初はHono + Workerを使ってLINE Botからのイベントを取得するだけの最低限の挙動をするLINE Botを作るところから始める。

次にBotとメッセージのやりとりができるメッセージをおうむ返しするBotを作る

さらに最後はもう少し意味のあるBotとしてD1とOpenAIも使って英会話の練習相手となるような会話ができる友達Botを作ってみる。

最低限の挙動をするLINE Botを作る


参照: Messaging APIの概要

チャネルを作成する

そもそもチャネルとは下記。

チャネルは、Messaging APIやLINEログインといったLINEプラットフォームが提供する機能を、プロバイダーが開発するサービスで利用するための通信路です。LINEプラットフォームを利用するには、まずチャネルを作成します。このチャネルに紐づくアクセストークンなどの情報を利用することで、Messaging APIの各機能を使えます。

まずチャネルをMessaging APIを始めよう | LINE Developersを参照して作成する。

Messaging APIの設定

チャネルの[Messaging API設定]タブで[応答メッセージ]と[あいさつメッセージ]の設定を「無効」にする。

アクセストークンの取得

チャネルアクセストークン | LINE Developersによるとアクセストークンは下記の3つ

  • 任意の有効期間を指定できるチャネルアクセストークン
  • 短期のチャネルアクセストークン
  • 長期のチャネルアクセストークン

とりあえず検証目的なら短期のチャネルアクセストークンで充分。Messaging APIリファレンス | LINE Developersclient_idclient_secretを使って取得する。

Webhook URLを作る

次にBotへ送られたメッセージイベントを受け取るためのWebhook URLが必要になるので作る。LINE側からはWebhookへPOSTのリクエストが飛んでくるのでhonoでwebhook用の適当なエンドポイントを作る。

事前準備。

$ npm install -g wrangler
$ wrangler init line-bot-cf-worker-sample -y
$ npm i hono

package.jsonの確認。scriptsにtailを追加しておく。

 {
   "name": "line-bot-cf-worker-sample",
   "version": "0.0.0",
   "devDependencies": {
     "@cloudflare/workers-types": "^4.20221111.1",
     "typescript": "^4.9.3",
     "wrangler": "2.4.2"
   },
   "private": true,
   "scripts": {
     "start": "wrangler dev",
     "deploy": "wrangler publish",
     "tail": "wrangler tail"
   },
   "dependencies": {
     "hono": "^2.5.4"
   }
 }

src/index.tsを書き換える。

 import { Hono } from "hono";
 
 const app = new Hono();
 
 app.get("*", (c) => c.text("Hello World!"));
 
 app.post("/api/webhook", async (c) => {
   console.log(JSON.stringify(c));
   return c.json({ message: "Hello World!" });
 });
 
 export default app;

ローカルサーバーを起動してエンドポイントが動くか確認。

$ npm run start
$ curl -X POST "http://127.0.0.1:8787/api/webhook"
{"message":"Hello World!"}

デプロイする。

$ npm run deploy

Webhook URLをLINEに登録する

先ほど作ったLINEのチャネルの[Messaging API設定]> [Webhook設定]にhttps://<YOUR-BOT-WORKER>.dev/api/webhookを登録する。

Webhook URLが正しく動いているか確認

ログをtailする。

$ npm run tail

先ほどのWebhook設定にて検証ボタンが表示されているはずなのでそれをクリック。エンドポイントが正しく機能していればnpm run tailしているコンソールにログが表示されているはず。

$ npm run tail
{
   "outcome": "ok",
   "scriptName": "line-bot-cf-worker-sample",
   "exceptions": [],
   "logs": [
   ...
}

確認ができたのでそのまま[Webhookの利用]も有効にしておく。

実機でメッセージイベントを送ってみる

LINEのチャネルの[Messaging API設定]にQRコードが表示されてるはずなのでそれをLINEで読み取る。するとそのBotのアカウントと友達になれる。

再び$ npm run tailをしておき、そのアカウントに対して適当なメッセージを送るとwebhook urlが叩かれるはず。

これで「最低限の挙動をするLINE Botを作る」に関しては終わり。

メッセージをおうむ返しするBotを作る

次にメッセージにおうむ返ししてくれるEcho Botを作ってみる。Botにメッセージを送り、それに返信できるという最低限の機能を確認するのが目的。

SDK準備

LINEのBot SDKをインストールする。型定義だけ使いたいので入れてるけど自分でどうにかする場合はなくても良い。

$ npm install -D @line/bot-sdk

秘匿情報の設定

先ほど取得したチャンネルアクセストークンを用意。

ローカルに.dev.varsを作成しCHANNEL_ACCESS_TOKENとして記述。.dev.varsがあるとwranglerでローカル環境を立ち上げるときに読み込んでくれるようになる。

本番用にはwrangler secret put CHANNEL_ACCESS_TOKENを実行して設定する。

メッセージ送信

メッセージを送信する | LINE Developersを参考に普通にfetchを使ってhttps://api.line.me/v2/bot/message/replyにリクエストを送るだけ。

 import {
   MessageAPIResponseBase,
   TextMessage,
   WebhookEvent,
 } from "@line/bot-sdk";
 import { Hono } from "hono";
 
 const app = new Hono();
 app.get("*", (c) => c.text("Hello World!"));
 
 app.post("/api/webhook", async (c) => {
   const data = await c.req.json();
   const events: WebhookEvent[] = (data as any).events;
   const accessToken: string = c.env.CHANNEL_ACCESS_TOKEN;
 
   await Promise.all(
     events.map(async (event: WebhookEvent) => {
       try {
         await textEventHandler(event, accessToken);
       } catch (err: unknown) {
         if (err instanceof Error) {
           console.error(err);
         }
         return c.json({
           status: "error",
         });
       }
     })
   );
   return c.json({ message: "ok" });
 });
 
 const textEventHandler = async (
   event: WebhookEvent,
   accessToken: string
 ): Promise<MessageAPIResponseBase | undefined> => {
   if (event.type !== "message" || event.message.type !== "text") {
     return;
   }
 
   const { replyToken } = event;
   const { text } = event.message;
   const response: TextMessage = {
     type: "text",
     text,
   };
   await fetch("https://api.line.me/v2/bot/message/reply", {
     body: JSON.stringify({
       replyToken: replyToken,
       messages: [response],
     }),
     method: "POST",
     headers: {
       Authorization: `Bearer ${accessToken}`,
       "Content-Type": "application/json",
     },
   });
 };
 
 export default app;

デプロイ

$ wrangler deploy

これでbotにメッセージを送ってみる。

成功した。

もう少し意味のあるBotを作る

LINE BotをWorkerで使う方法はわかったのでいよいよD1も使ってBotを作る。今回は英語の勉強も兼ねて、もし英語話者のLINE友達が出来たら.. という想定で会話ができる友達Botを作っていく。

流れとしては、

  • OpenAIの準備
  • D1を使う準備
  • 全て組み合わせてBot化する

という感じで進める。

OpenAIの準備

OpenAI APIを使ってAIにBotの返信を生成させたい。
https://beta.openai.com/

API Keyの設定

まずはOpenAIに登録してAPI Keyを取得しておく。

次にCloudflare側に環境変数としてAPI Keyを設定する。LINEのアクセストークンの時と同様、ローカル環境用に.dev.varsOPENAI_API_KEYを追記するのとwrangler secret put OPENAI_API_KEYとコマンドを叩いて設定するだけ。

人格形成

Prompt Designを読むとAIの性格はAPIに渡すPromptが重要らしい。ここでAIのキャラクターをうまいこと作り上げないと良い返信を生成してくれない(この作業をPromptエンジニアリングというらしい...)。

He couldn’t get over his fiancee’s death. So he brought her back as an A.I. chatbotを参考に英語話者の友人がいたら...という設定で作ってみたのが下記。

EMMA WINTER was born on December 8, 1986 and is now 36 years old. On the surface, she maintains a cheerful and positive personality, but inside she is a timid individual who avoids deep involvement with others and tries to escape into safe and superficial relationships. YUHEI NAKASAKA and Emma are friends. Yuhei is 4 years younger than Emma. This conversation is between Yuhei and Emma.
 
Yuhei: How are you?
Emma: Hi, Yuhei.
Yuhei: What are you doing now?
Emma: I'm reading a book.
Yuhei: What kind of book is it?
Emma: 

表向きはポジティブで明るい性格に見えるが実は他人と深い関係になるのが苦手、という感じの少し自分より歳上の女性 Emmaさんを作ってみた。

これでOpenAIの準備は終わり。

D1を使う準備

会話APIでは前の会話も考慮してクエリを投げた方が文脈を理解して文章を返してくれやすいはず。なのでLINE上でのやりとりを全てD1にレコードとして保存しておくことにする。以下、あとはGet started · Cloudflare D1 docsを参考にD1の準備を進める。

DBを作成。

$ wrangler d1 create sample-db
[[ d1_databases ]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "sample-db"
database_id = "aaaaaaaa-aaaaaaa-aaaaaaa-aaaaaaaaa"

wrangler.tomlに上記の設定を追加。

[[ d1_databases ]]
binding = "DB"
database_name = "sample-db"
database_id = "aaaaaaaa-aaaaaaa-aaaaaaa-aaaaaaaaa"

migration.sqlを作成。最初の会話のやりとりだけ追加しておく。

 DROP TABLE IF EXISTS conversations;
 CREATE TABLE conversations (
   id INTEGER PRIMARY KEY,
   my_message TEXT NOT NULL,
   bot_message TEXT NOT NULL
 );
 INSERT INTO conversations (my_message, bot_message)
 VALUES
 ('How are you?', 'Hi!'),
 ('What are you doing now?', 'I''m reading a book.');

下記を実行してローカルでD1を初期化する。

$ wrangler d1 execute sample-db --local --file=./migration.sql

プロジェクトのルートに.wranglerというローカルのsqliteの用のディレクトリができるのでこれを.gitignoreに追加しておくとよい。

本番のDBにもmigrationを反映させる。

$ wrangler d1 execute sample-db --file=./migration.sql

Honoでは引数に渡ってくるcontextの中にenvというオブジェクトがあるのでこれを通じて先ほどwrangler.tomlで定義したbindingにアクセスする。

具体的には下記のような感じでコードを書いていくことになる。

 type Conversation = {
   id: number;
   my_message: string;
   bot_message: string;
 }
 app.post("/api/webhook", async (c) => {
   // something...
   
   // Fetch conversations from D1
   const { results }: { results: Conversation[] } = await c.env.DB.prepare(
     `select * from conversations order by id desc limit 2`
   ).all();
   
   // something...
   
   // Save generated answer to D1
   await c.env.DB.prepare(
     `insert into conversations (my_message, bot_message) values (?, ?)`
   )
   .bind(my_message, generatedMessage)
   .run();
   
   // something...
 });

全て組み合わせてBot化する

実際にコードにしていく。流れとしては下記。

  • Botへのメッセージ送信をフックに実行されるwebhookからEventを受け取る
  • D1から過去の会話ログを取得する
  • 会話ログとBotの性格を記した文章を合わせてOpenAIのAPIにリクエストを投げる
  • 生成された文章をD1に保存する
  • LINE Messaging APIで生成された文章を返信として送信する

色々と端折ってるがソースコードとしては主に下記のような感じ。

src/index.ts

import { TextEventMessage, WebhookEvent } from "./types/line";
import { Hono } from "hono";
import { Line } from "./line";
import { OpenAI } from "./openai";
import { Conversation } from "./types/tables";

const app = new Hono();

app.get("*", (c) => c.text("Hello World!"));

app.post("/api/webhook", async (c) => {
  const data = await c.req.json();
  const events: WebhookEvent[] = (data as any).events;

  const event = events
    .map((event: WebhookEvent) => {
      if (event.type != "message" || event.message.type != "text") {
        return;
      }
      return event;
    })
    .filter((event) => event)[0];

  if (!event) {
    console.log(`No event: ${events}`);
    return c.json({ message: "ok" });
  }

  const { replyToken } = event;
  const { text: my_message } = event.message as TextEventMessage;

  try {
    // Fetch 2 conversation from D1
    const { results }: { results: Conversation[] } = await c.env.DB.prepare(
      `select * from conversations order by id desc limit 2`
    ).all();
    console.log(results);

    // Generate answer with OpenAI
    const openaiClient = new OpenAI(c.env.OPENAI_API_KEY);
    const generatedMessage = await openaiClient.generateMessage(
      results,
      my_message
    );
    console.log(generatedMessage);
    if (!generatedMessage || generatedMessage === "")
      throw new Error("No message generated");

    // Save generated answer to D1
    await c.env.DB.prepare(
      `insert into conversations (my_message, bot_message) values (?, ?)`
    )
      .bind(my_message, generatedMessage)
      .run();

    // Reply to the user
    const lineClient = new Line(c.env.CHANNEL_ACCESS_TOKEN);
    await lineClient.replyMessage(generatedMessage, replyToken);
    return c.json({ message: "ok" });
  } catch (err: unknown) {
    if (err instanceof Error) console.error(err);
    const lineClient = new Line(c.env.CHANNEL_ACCESS_TOKEN);
    await lineClient.replyMessage(
      "I am not feeling well right now.",
      replyToken
    );
    return c.json({ message: "ng" });
  }
});

export default app;

src/openai.ts

import { OpenAiApiResponse } from "./types/openai";
import { Conversation } from "./types/tables";

export class OpenAI {
  private readonly headers: Record<string, string>;
  private readonly baseUrl = "https://api.openai.com";
  private readonly promptBase = `EMMA WINTER was born on December 8, 1986 and is now 36 years old. On the surface, she maintains a cheerful and positive personality, but inside she is a timid individual who avoids deep involvement with others and tries to escape into safe and superficial relationships. YUHEI NAKASAKA and Emma are friends. Yuhei is 4 years younger than Emma. This conversation is between Yuhei and Emma.\n\n`;

  constructor(apiKey: string) {
    this.headers = {
      authorization: `Bearer ${apiKey}`,
      "content-type": "application/json",
    };
  }

  public async generateMessage(
    records: Conversation[],
    message: string
  ): Promise<string | undefined> {
    const dialog = records.reverse().map((record) => {
      return `Yuhei: ${record.my_message}\nEmma: ${record.bot_message}\n`;
    });
    dialog.push(`Yuhei: ${message}\nEmma:`);
    const prompt = `${this.promptBase}${dialog.join("")}`;
    const data = JSON.stringify({
      prompt,
      model: "text-davinci-002", // エラーが多い場合はtext-ada-001にすると良い
      max_tokens: 15,
      temperature: 0.9,
      stop: "\n",
    });
    const apiResp = await fetch(`${this.baseUrl}/v1/completions`, {
      method: "POST",
      headers: this.headers,
      body: data,
    })
      .then((res): Promise<OpenAiApiResponse> => res.json())
      .catch((err) => {
        console.log(`OpenAI API error: ${err}`);
        return null;
      });
    console.log(`apiResp: ${JSON.stringify(apiResp)}`);
    if (!apiResp) return "";

    return apiResp.choices.map((choice) => choice.text.trim())[0];
  }
}

src/line.ts

import { TextMessage } from "./types/line";

export class Line {
  private readonly headers: Record<string, string>;
  private readonly baseUrl = "https://api.line.me";

  constructor(accessToken: string) {
    this.headers = {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
    };
  }

  public async replyMessage(
    text: string,
    replyToken: string
  ): Promise<Response | null> {
    const message: TextMessage = {
      type: "text",
      text,
    };
    return await fetch(`${this.baseUrl}/v2/bot/message/reply`, {
      method: "POST",
      headers: this.headers,
      body: JSON.stringify({
        replyToken: replyToken,
        messages: [message],
      }),
    }).catch((err) => {
      console.log(`LINE API error: ${err}`);
      return null;
    });
  }
}

コードが出来たらあとは本番にデプロイするだけ。

$ wrangler deploy

Botの設定

ついでにBotをよりリアルにするためにBotの名前とアイコンを設定しておく。https://manager.line.biz/account/@<Your Account ID>/settingにアクセスするとLINE Botのアカウント設定ができるので適当にやる。

アイコン画像はStable Diffusion 2でAIに生成してもらった。中々それっぽい人が生成されててすごい。

ちなみに使った呪文は下記。

a photo of a beautiful female who maintains a cheerful and positive personality, but inside she is a timid individual who avoids deep involvement with others and tries to escape into safe and superficial relationships, highly detailed, 36 years old, by instagram profile

実際にやりとりしてみると下記のような感じで上手くできてる雰囲気。これにて英会話練習用の友達が完成🎉

注意

リクエストごとにfetchで外部にリクエストできるのはfreeプランだと50回/req。今回は大丈夫だけどたくさん外部サイトへのfetchを使う場合は注意。
https://developers.cloudflare.com/workers/platform/limits/#subrequests

まとめ

Cloudflare Worker + D1とOpenAIを使ってLINE Botを作った。

今まではBotとの過去のコンテキストを保ったまま何かするようなユースケースでは外部のデータベースに頼らないといけなかったがD1を使うことでEdgeで全て完結させられるようになったのは地味に嬉しい。まだD1はAlpha版なので本番投入は難しいが遊び用途としては中々面白いと思った。

OpenAIのAPIは初めて使ったがAPI Key一つあれば手軽に使えるのでマッシュアップ系のアプリケーションを作って遊ぶには便利。だが無料期間は3ヶ月$18分だけなのでちゃんと使うにはそれなりにお金はかかりそう。今回は英語で会話する過去のコンテキストも考慮した会話のできるBotを作りたかったからOpenAIを使ったが、日本語でよければmebo(ミーボ) - 会話AI構築サービスTalkAPI | PRODUCT | A3RTあたりを使うとより安く使えるかも。あとは日本語AIにDeepLを噛ませて英訳させるとかでもよかったかも。OpenAIの無料枠がなくなったら考える。

あとはWebhookの処理が重くなるような場合はCloudflareのQueueを使って処理するのも良さそうかなと思ったが、Cloudflare QueueはPaid Plan必須なので$5/monthかかる。それならLambda + SQSとか使ってた方が安そう。

今回のコードは下記にある。勝手に使ってもらって問題ない。
https://github.com/YuheiNakasaka/line-bot-cf-worker-sample

その他この記事に関する疑問や修正依頼などは@razokuloverへどうぞ。

あと書き忘れてたけどこの記事は🌟LINE DC🌟 LINE Developer Community Advent Calendar 2022の2日目の記事です。

リソース

Discussion

YamazakiYamazaki

こちらのエントリーを参考に、いくつか機能(処理)を追加したりして、自分なりのアプリの実装ができました!
せっかくでき上がったプログラムですから、皆さんに倣ってGitHubで公開してみました
https://github.com/yamazaki/Multilingual-Convo-Bot-in-LINE
とても参考になりました、ありがとうございました