ChatGPT API を使って LINE ボットを作成した

少し前ですが ChatGPT API が公開されました

以前から ChatGPT をいい感じに使いたいな〜と思っていて、ちょうど実家に帰った際に最近の AI は凄いぜ!的な話をしており、LINE の家族グループに「ボットくん」として参加させてみることを思い立ったというのがやってみた経緯です。

大まかな構成

使用する主要な各サービスは以下です。

ざっくりとした図ではこんな感じ。

だいたい時計回りに処理が進んでいく感じです。

LINE の Messaging API を使用することでユーザーとボットの双方向のやり取りが可能になります。Messaging API は Webhook を有効化することで、ユーザーが友だち追加したりメッセージを送信した際、指定した URL に対してイベントを送信できます。

今回は API Gateway の HTTP API で作成した API のエンドポイントが Webhook に指定する URL となります。

HTTP API で作成した API は統合先に Lambda 関数を設定してあり、この Lambda 関数で LINE から受け取ったメッセージを ChatGPT に送信したり、ChatGPT から受け取ったメッセージを LINE に返すなどしています。

DynamoDB は LINE から送られてきたユーザーのメッセージ、ChatGPT から返ってきたメッセージを逐一保存しています。以下の属性名で管理しています。

  • id: UUID で生成した一意の値。
  • content: LINE, ChatGPT から受け取ったメッセージ。
  • createdAt: メッセージを受け取った日時。
  • lineUserId: メッセージを送信した LINE ユーザーの ID。
  • role: user, assistant のいずれかが入る (後述します)。

role について。ChatGPT API にメッセージを送信する際、メッセージに role (役割) を含める必要があります。role は user, assistant, system の 3 種類がありそれぞれ以下を意味します。

  • user: ChatGPT にメッセージを送る人。大抵の場合人間。
  • assistant: ChatGPT の中の人。つまり AI。
  • system: assistant の動作を設定するのに使われる。例えば「あなたは絵文字を多用するおじさんです」のような内容を指定すればおじさん構文で返すようになる、みたいなイメージw

今回の場合特に system ロールは使わないので、user と assistant のみ DynamoDB に保管されるようにしています。

で、なぜ DynamoDB にいちいちメッセージを保管しているかというと、文脈を維持した会話を行いたいからです。ChatGPT では過去のやりとりを含めてメッセージを送信することで、文脈を維持した会話 (しりとり等) が可能になっています。

以下の例で user と assistant のやりとりをまとめて送信している部分がそれです。

import openai

openai.ChatCompletion.create(
  model="gpt-3.5-turbo",
  messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Who won the world series in 2020?"},
        {"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
        {"role": "user", "content": "Where was it played?"}
    ]
)
https://platform.openai.com/docs/guides/gpt/chat-completions-api

つまり、DynamoDB から過去何件か分の会話を取得し、最新の会話と一緒に送ることで文脈を維持した会話が可能になっています。全ての会話を取得するのは現実的でない (ChatGPT はトークンと呼ばれる、テキストデータを分割する最小単位ベースで課金があり、毎回全やりとり送っていたら破産するw) ので、今のところ 5 件分取得して送っています。

…ということはしりとりで 2 ターン半経過したら、それより前の単語は再び使えるようになってしまうんですかね?w

ちなみに API Gateway 等のバックエンドの各リソースは SAM で管理しています。Lambda 関数で使用する言語は Python を使用しています。

ボットを作っていく中でいくつか工夫、というほどのものではないかもですが、考えたものは以下です。

工夫した点

「ボットくん」と言う文字列が入っていないと反応しないようにした

グループに入れると全てのメッセージに反応してしまうので非常に鬱陶しく、「ボットくん」という文字列が入っていない場合は反応しないようにしてみました。単純にメッセージ中に「ボットくん」という文字列が入っていなければ return して終了するだけですが重要な機能です。

if 'ボットくん' not in message:
    print('「ボットくん」という文字列が含まれていないため終了。')
    return

文脈を維持するようにした

既に書いた通り、DynamoDB に会話の履歴を残すようにしており、最新の会話から数件分を取得しそれを ChatGPT へのメッセージに含めることで文脈を維持した会話が可能になりました。

しりとり だって できるぜ!

また、DynamoDB からメッセージを取得する際、送信元の LINE ユーザー ID (lineUserId) でフィルタし取得しているので、例えばグループ内で複数のユーザーから同時に使用された場合も文脈が混ざっておかしなことにならないようにしています。

今書いてて思ったのですが、グループ ID も保存するようにすればさらに厳密に文脈維持できそうですね。例えば複数のグループで使用したとして、ユーザー ID でだけフィルタしていると、あるユーザーがグループで使用した際の文脈を別のグループでの会話に引き継いでしまうので。

LINE の署名検証

API Gateway で HTTP API を作成し LINE からの Webhook を受け取るインターフェースを作成することは既に書いた通りですが、そのままでは LINE 以外からのリクエストも受け付けてしまいます。それはよろしくないので LINE 以外からのメッセージは処理しないようにしたいです。

すぐ思いつくのは IP アドレス制限ですが、LINE のドキュメントを見ると、LINE プラットフォームで使用される IP アドレスは公開していないとのことです。

代わりの方法として、LINE から送られてくるリクエストに含まれる x-line-signature ヘッダーの署名を検証する方法が案内されています。

署名の検証のサンプルコードも案内されており親切。以下は Python の例。

import base64
import hashlib
import hmac

channel_secret = '...' # Channel secret string
body = '...' # Request body string
hash = hmac.new(channel_secret.encode('utf-8'),
    body.encode('utf-8'), hashlib.sha256).digest()
signature = base64.b64encode(hash)
# Compare x-line-signature request header and the signature
https://developers.line.biz/ja/reference/messaging-api/#signature-validation

この署名の検証を Lambda 関数で行えば LINE 以外から送られたメッセージの場合終了するように出来ます。

意図しないユーザー、グループから使われた場合通知

LINE の Messaging API には、プライベート向けに閉じるような機能がありません。つまり、誰でも友だち追加が可能です。身内でしか使っていないので、無いとは思いますが、念の為、意図しないユーザーやグループから使われた場合は LINE に通知メッセージを送信するようにしてみました。

Webhook から送られるイベントにはユーザー ID やグループ ID が含まれています。なのであらかじめユーザー ID やグループ ID をリストしておき、そこに含まれない ID から送られてきた場合には通知を送信する形で実現しています。通知は LINE Notify を使用して LINE に送っています。

どんなメッセージが送信されたかもわかるようになっています。

なお、事前にユーザー ID やグループ ID を取得する方法は無いので、使用予定のユーザーやグループから実際に送信してみてログなどから確認する必要があります。

エラーが発生した場合はエラーを送信する

ChatGPT 使用時に何らかのエラーが発生した場合、LINE ユーザーから見ると単に時間がかかっているのか、エラーで停止しているのか判断できません。そこでエラーが発生した場合はエラーを返すようにしました。

わざとスロットリングさせてみた例。

早速家族グループに入れてみた

普段使っている LINE グループでそのまま使えるので、私の父や母の世代でも違和感なく使えておりボットくんとして親しまれております。😊

生意気なちびすけとして知られるボットくん。

ChatGPT 面白いですねぇ。最近 ChatGPT が Plugin 対応したので、より便利に使うべく試してみようかなと思ってます。とりあえず提示した URL を読んでくれるようにしたい。けどこれサイトによってはスクレイピングに該当したりするのかな。そしたら微妙かも。とにかく、夢がひろがりんぐ✨でありますな。

課題

上で挙げたように LINE の署名を検証したり、「ボットくん」という文字列が入っていないメッセージは処理しないようにしたり、意図しないユーザーやグループからの使用を検知するようにしたり、これって本当は HTTP API の Lambda オーソライザーでやれたら綺麗だなと思うんです、が、Lambda オーソライザーはヘッダーやクエリ文字列は受け取れるもののボディは受け取れないんですよね。なので断念。ちなみに REST API でも同じくボディは渡せない。

あと上でもちらっと書きましたが現在 DynamoDB から過去のメッセージを取得する際に LINE のユーザー ID でフィルタしていますがグループ ID も使うことでより厳密な文脈の維持ができそう。