Cloudflare Worker + D1でLINE Botを作る
最低限の挙動をするLINE Botを作る
チャネルを作成する
チャネルとは
チャネルは、Messaging APIやLINEログインといったLINEプラットフォームが提供する機能を、プロバイダーが開発するサービスで利用するための通信路です。LINEプラットフォームを利用するには、まずチャネルを作成します。このチャネルに紐づくアクセストークンなどの情報を利用することで、Messaging APIの各機能を使えます。
Messaging APIの設定
チャネルの[Messaging API設定]タブで[応答メッセージ]と[あいさつメッセージ]の設定を「無効」にする
アクセストークの取得
任意の有効期間を指定できるチャネルアクセストークン
短期のチャネルアクセストークン
長期のチャネルアクセストークン
Webhook URLを作る
Botへ送られたイベントを受け取るためのWebhook URLが必要
LINE側からはWebhookへPOSTのリクエストが飛んでくる
honoでwebhook用の適当なエンドポイントを作る
事前準備。
code:sh
$ wrangler init line-bot-cf-worker-sample -y
$ npm i hono
package.jsonの確認。scriptsにtailを追加しておく。
code:json
{
"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を書き換える。
code: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;
ローカルサーバーを起動してエンドポイントが動くか確認。
code:sh
$ npm run start
{"message":"Hello World!"}
デプロイする。
code:sh
$ npm run deploy
Webhook URLをLINEに登録する
先ほど作ったLINEのチャネルの[Messaging API設定]> [Webhook設定]にhttps://<YOUR-BOT-WORKER>.dev/api/webhookを登録する。
Webhook URLが正しく動いているか確認
ログをtailする。
code:sh
$ npm run tail
先ほどのWebhook設定にて検証ボタンが表示されているはずなのでそれをクリック。エンドポイントが正しく機能していればnpm run tailしているコンソールにログが表示されているはず。
code:sh
$ 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が叩かれるはず。
これで準備はOK。
メッセージをおうむ返しするEcho Botにする
LINEのSDKをインストールする。
code:sh
$ npm install @line/bot-sdk --save
Workersの[Settings]>[Variables]>[Environment Variables]にてLINEのチャンネルシークレットと先ほど取得したチャンネルアクセストークンを用意。ローカルに.dev.varsを作成しそれぞれCHANNEL_SECRETとCHANNEL_ACCESS_TOKENとして記述。本番用にはwrangler secret put CHANNEL_SECRETとwrangler secret put CHANNEL_ACCESS_TOKENを実行しそれぞれ設定する。
code:ts
import {
Client,
ClientConfig,
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 clientConfig: ClientConfig = {
channelAccessToken: c.env.CHANNEL_ACCESS_TOKEN || "",
channelSecret: c.env.CHANNEL_SECRET,
};
const client = new Client(clientConfig);
await Promise.all(
events.map(async (event: WebhookEvent) => {
try {
await textEventHandler(client, event);
} catch (err: unknown) {
if (err instanceof Error) {
console.error(err);
}
return c.json({
status: "error",
});
}
})
);
return c.json({ message: "ok" });
});
const textEventHandler = async (
client: Client,
event: WebhookEvent
): 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 client.replyMessage(replyToken, response);
};
export default app;
すると失敗。SDKがNode.jsしか想定してないのでEdge Runtimeで動かない。主に出てるエラーは下記。stream/querystring/crypto/zlib/events/fs/path/bufferがだめ。
code:txt
node_modules/@line/bot-sdk/dist/http.js:4:25:
4 │ const stream_1 = require("stream");
╵ ~~~~~~~~
node_modules/@line/bot-sdk/dist/http.js:7:19:
7 │ const qs = require("querystring");
╵ ~~~~~~~~~~~~~
node_modules/@line/bot-sdk/dist/validate-signature.js:3:25:
3 │ const crypto_1 = require("crypto");
╵ ~~~~~~~~
node_modules/body-parser/lib/read.js:20:19:
20 │ var zlib = require('zlib')
╵ ~~~~~~
node_modules/body-parser/lib/types/urlencoded.js:228:20:
228 │ mod = require('querystring')
╵ ~~~~~~~~~~~~~
node_modules/destroy/index.js:15:27:
15 │ var EventEmitter = require('events').EventEmitter
╵ ~~~~~~~~
node_modules/destroy/index.js:16:25:
16 │ var ReadStream = require('fs').ReadStream
╵ ~~~~
node_modules/destroy/index.js:17:21:
17 │ var Stream = require('stream')
╵ ~~~~~~~~
node_modules/destroy/index.js:18:19:
18 │ var Zlib = require('zlib')
╵ ~~~~~~
node_modules/mime-types/index.js:16:22:
16 │ var extname = require('path').extname
╵ ~~~~~~
node_modules/safe-buffer/index.js:3:21:
3 │ var buffer = require('buffer')
╵ ~~~~~~~~
node_modules/safer-buffer/safer.js:5:21:
5 │ var buffer = require('buffer')
╵ ~~~~~~~~
node_modules/strtok3/lib/FsPromise.js:7:19:
7 │ const fs = require("fs");
╵ ~~~~
wrangler.tomlにnode_compat = trueを追加するとnode.jsでも動くようにpolyfillしてくれるようになるので記載。
またデプロイして試す。が、adapter errorみたいなやつが出る。node_compat = trueにしても全てをpolyfillしてくれるわけではなくfsとかが使われてるとダメっぽい。
code:ts
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,
};
body: JSON.stringify({
replyToken: replyToken,
}),
method: "POST",
headers: {
Authorization: Bearer ${accessToken},
"Content-Type": "application/json",
},
});
};
export default app;
あとclient.replyMessage(replyToken, response)を使わなくなったので@line/bot-sdkはdevDependenciesで充分になった(WebhookEventとかの型定義も不要ならそもそもこのライブラリ自体使わなくても良い)。それとwrangler.tomlに記載してあったnode_compat = trueももう不要なので消してOK。
成功した。
https://scrapbox.io/files/637e171cb268ba0023207015.PNG
もう少し意味のあるbotを作る
会話APIを使う
翻訳APIを使う
deepL
D1を使う
uidをidにしてapiのkeyやtokenの設定
Queueを使う
メッセージの送信処理
700円/月かかる...
webhookのレスポンス制限
応答の制限
応答トークンは、Webhookを受信してから1分以内に使用する必要があります。1分を超える場合の使用については、動作は保証されません。
OpenAIの会話APIを使う準備
今回はOpenAI APIのAPIしか使わないし複雑でもないので普通にスクラッチでfetchを書く。 あとは実際にOpenAIで生成する会話の返信を考える。Promptエンジニアリングを読むとAIに渡すPromptが重要らしい。ここでAIのキャラクターをうまいこと作り上げないと良い返信を生成してくれない。 code:txt
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:
表向きはポジティブで明るい性格に見えるが実は他人と深い関係になるのが苦手という感じの少し歳上の女性にしてみた。
Cloudflare側にOpenAIのAPI Keyを設定する。LINEのアクセストークンの時と同様、wrangler secret put OPENAI_API_KEYとコマンドを叩いて設定するだけ。
D1を使う準備
会話APIでは前の会話も考慮してクエリを投げた方が文脈を理解して文章を返してくれそうなのでやりとりを全てD1に保存しておくことにする。
DBを作成する。
code:sh
$ 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に上記の設定を追加。
code:toml
d1_databases
binding = "DB"
database_name = "sample-db"
database_id = "aaaaaaaa-aaaaaaa-aaaaaaa-aaaaaaaaa"
migration.sqlを作成。最初のやりとりだけ追加しておく。
code: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を初期化する。
code:sh
$ wrangler d1 execute sample-db --local --file=./migration.sql
プロジェクトのルートに.wranglerというローカルのsqliteの用のディレクトリができるのでこれを.gitignoreに追加しておくとよい。
本番のDBにもmigrationを反映させる。
code:sh
$ wrangler d1 execute sample-db --file=./migration.sql
Honoでは引数に渡ってくるcontextの中にenvというオブジェクトがあるのでこれを通じて先ほどwrangler.tomlで定義したbindingにアクセスする。具体的には下記のような感じでコードを書いていくことになる。
code:ts
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で生成された文章を返信として送信する
TODO: ここにソースコード
まとめ
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とか使ってた方が安そう。
今回のコードは下記にある。勝手に使ってもらって問題ない。
リソース