GPT-4VとDALL-E 3で絵しりとりLINE Botを作っちゃおう

LINE Developer Community 2023年のアドベントカレンダーの2日目、やらせていただきます💪

ということでね、タイトルの通り絵しりとりできるLINE Botを作りたいと思います。画像入出力のUIとか、普通にアプリ作ろうとすると作るのが大変なんですが、LINE Botだとこの辺が最初から準備されているのでとても簡単に作ることができます。AIの進歩が早い今日この頃、シュッと何か作るときにLINE Botは最高の選択肢ですね！

それでは、やってまいりましょう。

# 🦜 LINE Botのガワを作る

はじめにおうむ返しまで作ります。アクセストークンの取得とかWebHook URLの登録とかはLINE Developerのサイトでやっておいてください。このへんのサイトの手順通りでOKです。

https://qiita.com/cog1t0/items/0d121ee6d48a8c1634fc

関連するライブラリーもこのタイミングで全部入れちゃいましょう。

```sh
$ pip install line-bot-sdk openai uvicorn fastapi
```

以下の通りアプリケーションを書いていくファイルも作ります。いきなり説明もなく50行くらいあって面食らっちゃうかもしれないのですが、`handle_events`関数がLINEサーバーから飛んでくるイベントを処理する部分です。ユーザーが送信したメッセージはメッセージイベントとしてここで受け取り、ユーザーが入力した文言である`ev.message.text`を、そのまま`line_api.reply_message`に渡して送信することでおうむ返しになっています。

```python:run.py
from contextlib import asynccontextmanager
import aiohttp
from linebot import AsyncLineBotApi, WebhookParser
from linebot.aiohttp_async_http_client import AiohttpAsyncHttpClient
from linebot.models import TextSendMessage
from fastapi import FastAPI, Request, BackgroundTasks

# Tokens
YOUR_CHANNEL_ACCESS_TOKEN = ""
YOUR_CHANNEL_SECRET = ""

# LINE Messagin API resources
session = aiohttp.ClientSession()
client = AiohttpAsyncHttpClient(session)
line_api = AsyncLineBotApi(
    channel_access_token=YOUR_CHANNEL_ACCESS_TOKEN,
    async_http_client=client
)
parser = WebhookParser(channel_secret=YOUR_CHANNEL_SECRET)

# Preparing FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await session.close()

app = FastAPI(lifespan=lifespan)

# Handler for events from LINE
async def handle_events(events):
    for ev in events:
        await line_api.reply_message(
            ev.reply_token,
            TextSendMessage(
                text=ev.message.text
            )
        )

# WebHook request handler
@app.post("/linebot")
async def handle_request(request: Request, background_tasks: BackgroundTasks):
    events = parser.parse(
        (await request.body()).decode("utf-8"),
        request.headers.get("X-Line-Signature", "")
    )
    background_tasks.add_task(handle_events, events=events)
    return "ok"
```

起動します。

```sh
$ uvicorn run:app
```

この操作をPCでおこなっている場合は、[ngrok]()などのトンネリングツールを使ってLINEサーバーからインターネット経由でアクセスできるようにしましょう。

```sh
ngrok http 8000
```

おうむ返しできましたね！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/53296/8968e355-b602-57d0-e8e7-9d97b1d6c5fe.png)



# 🖼️ 画像入力・画像応答に対応する

テキストのやり取りを作りましたが、画像の入力と画像の応答に対応します。絵しりとりなのでこっちがメインになります。

## 画像を受け取る

まずは画像の受け取り方について公式ドキュメントを見てみましょう。

https://developers.line.biz/ja/reference/messaging-api/#message-event

>画像ファイルのバイナリデータは、メッセージIDを指定してコンテンツを取得するエンドポイントを使用することで取得できます。

コンテンツを取得するエンドポイントはこちら。

https://developers.line.biz/ja/reference/messaging-api/#get-content

```
GET https://api-data.line.me/v2/bot/message/{messageId}/content
```

また、リクエストヘッダーに`Authorization: Bearer {channel access token}`を指定する必要があるようです。

## 画像を送る

こんどは送る方です。メッセージの送信そのものはおうむ返しと同じなので、テキストではなく画像を送るにはどうしたらいいかがポイントです。まずは公式ドキュメント。

https://developers.line.biz/ja/reference/messaging-api/#image-message

```json
{
  "type": "image",
  "originalContentUrl": "https://example.com/original.jpg",
  "previewImageUrl": "https://example.com/preview.jpg"
}
```

>画像フォーマット：JPEGまたはPNG
最大ファイルサイズ：10MB

オリジナルとプレビューの両方が必須なようです。プレビューは上限1MB。画像そのもののバイナリーデータを送るのではなく、HTTPSでアクセス可能な場所に保存してURLを送信するということですね！

## 画像おうむ返しを作る

画像が送られてきたら、それをimagesフォルダーに保存して、そのURLを送ることにします。まずは画像公開用に`images`フォルダーを作成して、そこにアクセスできるようにFastAPIの設定を追加します。

```python:run.py
　：省略
from fastapi.staticfiles import StaticFiles
　：省略
app = FastAPI(lifespan=lifespan)
app.mount("/images", StaticFiles(directory="images"), name="images")  # ←追加
```

ここに、たとえば`test.png`などを置いて http://127.0.0.1:8000/images/test.png で表示されることを確認しておきましょう。

下準備が整ったので、今度はイベントハンドラーを修正していきたいと思います。まずは画像送信メッセージオブジェクト`ImageSendMessage`をimport文に追加します。

```python
from linebot.models import TextSendMessage, ImageSendMessage
```

続いてイベントハンドラーの改造です。画像をダウンロードして保存して〜みたいなのは、LINEのSDKでやってくれます。感謝✨🙏✨🙏✨🙏✨

ややこしいのでオリジナルとプレビューとでURLを同じにしていたり、すべてPNG拡張子として扱っていますので今後ちゃんとやるときは分ける必要があるでしょう。

```python
# Handler for events from LINE
async def handle_events(events):
    for ev in events:
        if ev.message.type == "image":
            # Get and save image
            image_stream = await line_api.get_message_content(ev.message.id)
            with open(f"./images/{ev.message.id}.png", "wb") as f:
                async for chunk in image_stream.iter_content():
                    f.write(chunk)
            # Send image
            await line_api.reply_message(
                ev.reply_token,
                ImageSendMessage(
                    original_content_url=f"https://YOUR_DOMAIN/images/{ev.message.id}.png",
                    preview_image_url=f"https://YOUR_DOMAIN/images/{ev.message.id}.png"
                )
            )

        else:
            await line_api.reply_message(
                ev.reply_token,
                TextSendMessage(
                    text="画像を送ってね"
                )
            )
```

サーバーアプリを再起動してから（忘れがち）画像を送信してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/53296/03d00fdc-4811-0b0e-c24a-2b3c9025e750.png)

やったー🙌
なおこの画像はレゴで作った海老天🍤です。左に見える緑色は抹茶塩です。

# 👀 GPT-4Vで画像認識する

ChatGPTは画像を認識して会話の中で使用することができます。この機能を利用して、送った画像に写っているものをしりとりの相手が言った言葉として扱い、返すべき文言を考えてもらうようにします。

まずはChatGPTに画像を入力する方法を見ていきましょう。公式ドキュメントはこちら。Example requestをImage inputに切り替えるとよくわかります。

https://platform.openai.com/docs/api-reference/chat/create

ということで、まずはOpenAIのモジュールをインポート&設定といわゆるプロンプトの準備をしておきましょう。

```python
from openai import AsyncClient

OPENAI_API_KEY = "YOUR_API_KEY"
SYSTEM_CONTENT = """あなたは私としりとりをしています。以下の条件に基づいて応答してください。

* ユーザー送ってきた画像は、ユーザーのターンの単語です。
* その画像に写っているものを認識します。画像は写真ではなくユーザーによるドローイングの場合もあります。たとえば寿司が写っていれば、ユーザーのターンの単語は「寿司」です。
* 次に、あなたのターンの単語を考えます。たとえばユーザーのターンの単語が「寿司」であれば、その読みは「スシ」であり、読みの末尾は「シ」です。そのため、あなたの単語は「シカ」や「シロネコ」になります。
* あなたのターンの単語だけを応答します。たとえば「シカ」や「シロネコ」だけを応答します。検討過程やその他の文言を入れることは許されません。
* ユーザーのターンの単語を応答することは厳禁です。たとえばコーヒーの画像を見せられて「コーヒー」と回答することは許されません。

例

user: スシ
assistant: シカ
user: カイ
assistant: イカ
user: カラス
assistant: スミレ

以上です。ユーザーの送ってきた画像がよく分からなくても、ベストを尽くしてください。間違っても問題ありません。自信を持ってプレイしましょう！
"""

# OpenAI
openai_client = AsyncClient(api_key=OPENAI_API_KEY)
```

これらを使って、ユーザーが送ってきた画像に対するAI側の単語を応答するようにイベントハンドラーを修正します。画像応答は一旦コメントアウト。ChatGPTへの画像送信にもURLを使用しますが、それには先ほどimagesフォルダーに保存した画像にURLでアクセスできるようにしましたので、それを使用します。

```python
# Handler for events from LINE
async def handle_events(events):
    for ev in events:
        if ev.message.type == "image":
            # Get and save image
            image_stream = await line_api.get_message_content(ev.message.id)
            with open(f"./images/{ev.message.id}.png", "wb") as f:
                async for chunk in image_stream.iter_content():
                    f.write(chunk)

            # Talk with ChatGPT with image
            chatgpt_resp = await openai_client.chat.completions.create(
                model="gpt-4-vision-preview",
                messages=[
                    {
                        "role": "system", "content": SYSTEM_CONTENT
                    },
                    {
                        "role": "user",
                        "content": [
                            {"type": "text", "text": "これは私のターンの画像です。あなたのターンの単語を考えてください。"},
                            {"type": "image_url", "image_url": f"https://YOUR_DOMAIN/images/{ev.message.id}.png"}
                        ]
                    }
                ]
            )

            # Send assistant word (temporary)
            await line_api.reply_message(
                ev.reply_token,
                TextSendMessage(
                    text=chatgpt_resp.choices[0].message.content
                )
            )

            # # Send image
            # await line_api.reply_message(
            #     ev.reply_token,
            #     ImageSendMessage(
            #         original_content_url=f"https://YOUR_DOMAIN/images/{ev.message.id}.png",
            #         preview_image_url=f"https://YOUR_DOMAIN/images/{ev.message.id}.png"
            #     )
            # )

        else:
            await line_api.reply_message(
                ev.reply_token,
                TextSendMessage(
                    text="画像を送ってね"
                )
            )
```

それでは、再起動して画像を送ってみましょう！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/53296/7a0ec128-62c6-5c0b-a47b-e3257e82f13a.png)

1枚目はコーヒー→ヒヨドリでいい感じ、2枚目は・・・うーん、AI側が何と捉えたのでしょうね？！精度向上のためにプロンプトを工夫すると良さそうですが、とりあえず先に進みます！

# 🎨 DALL-E 3で画像を生成する

最後にAIの応答を画像にしていきます。まずは公式ドキュメント。

https://platform.openai.com/docs/api-reference/images/create

```python
client.images.generate(
    model="dall-e-3",
    prompt="A cute baby sea otter",
    n=1,
    size="1024x1024"
)
```

むちゃくちゃ簡単ですね！promptのところに、先ほどのChatGPTからの応答をはめ込めばOK👍ではありますが、写真っぽいのだとAIチート感があるので、「シンプルな手書き風」みたいなのを追加しておくことにしましょう。

また、DALL-E 3氏は日本語よりも英語の方が正しく理解してくれるようですので、英語に翻訳した上で画像生成してもらうようにします。先ほどのテキスト送信のところをコメントアウト（あとで削除します）して、英語に翻訳→画像生成→画像送信のコードを追加します。画像送信のところは、送信するURLをDALL-Eの生成結果のものに差し替えている点に注意してください。

```python
            # # Send assistant word (temporary)
            # await line_api.reply_message(
            #     ev.reply_token,
            #     TextSendMessage(
            #         text=chatgpt_resp.choices[0].message.content
            #     )
            # )

            # Translate to english to get better response from DALL-E 3
            assistant_word = chatgpt_resp.choices[0].message.content
            print(assistant_word)
            translated_resp = await openai_client.chat.completions.create(
                model="gpt-3.5-turbo",
                messages=[{"role": "user", "content": f"「{assistant_word}」を英語に翻訳してください。翻訳した単語だけを応答してください。"}]
            )
            assistant_word_en = translated_resp.choices[0].message.content
            print(assistant_word_en)

            # Generate image with DALL-E 3
            dalle_resp = await openai_client.images.generate(
                prompt=f"{assistant_word_en}. Quick and simple drawing with a bold pen."
            )
            print(dalle_resp.data[0].url)

            # Send image
            await line_api.reply_message(
                ev.reply_token,
                ImageSendMessage(
                    original_content_url=dalle_resp.data[0].url,
                    preview_image_url=dalle_resp.data[0].url
                )
            )
```

再起動してしりとりをプレイしてみましょう！！

![IMG_0741.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/53296/b9b7bcb0-a9bf-2f91-344d-bcfd51b44160.png)

？！（ネコザメらしい）

![IMG_0743.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/53296/d84624eb-49ba-36f0-8c63-3f6dc09fbf9b.png)

？？？？（emailらしい）

ということで、しりとりをルール通りにちゃんとプレイするにはプロンプトをしっかりと追い込む必要がありそうでしたが、画像の送受信で会話ができるようにはなったのでここまでとしましょう！個人的にはメールの絵をAIに理解してもらえたのが嬉しいです🥰🥰🥰


# おわりに

書いてたらちょっと長くなっちゃったんですけど、途中までやれば途中までの知識が身についたり、最後までやれば必ずこの通りにできますので、年末年始にチャレンジしてみてくださいね！

できあがりのコード全体は以下のGitHubリポジトリーに置いておきました。この記事が面白かった・役にたったと思ってくれた人はぜひ記事とGitHubにいいねしてください✨🙏✨とっても励みになります☺️

https://github.com/uezo/linebot-drawing-shiritori

それでは、来年もEnjoy creating awesome LINE Bot!!