OpenAIのChatGPT APIを利用してTypetalkボットを作成する

# はじめに
2023年3月に OpenAI の [ChatGPT API](https://platform.openai.com/overview) が公開されました｡下記の参考情報にあるように､ChatGPT APIを利用したLINEやSlackのボットが紹介されています｡本記事では､ヌーラボが提供するビジネスチャットツール [Typetalk](https://www.typetalk.com/ja/) でChatGPT APIを利用したボットを作成する方法を紹介します｡

# 参考情報
https://www.typetalk.com/ja/

https://developer.nulab.com/ja/docs/typetalk/

https://dev.classmethod.jp/articles/chatgpt-api-line-bot-aws-serverless/

https://studist.tech/azure-openai-service-on-slack-392019d6411d

https://okumuralab.org/~okumura/python/chatgpt_api.html

https://data-analytics.fun/2021/12/01/gpt-3-api/

https://platform.openai.com/docs/introduction/overview

https://bottlepy.org/

https://ngrok.com/

# GitHubリポジトリ
https://github.com/revsystem/typetalk-bot-chatgpt

# ゴール
Typetalkトピック内で､ChatGPT APIを利用してTypetalkボットと会話できるようにします｡
![ChatGPT_API_and _Typetalk.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/dcb11703-7c52-097a-ddb9-058a81c80ff0.png)

Typetalkボットは以下の仕様で動作します｡
- あらかじめ設定されたキャラクター設定に従った口調､回答をする
- 会話の起点となる発言(スレッドのいちばん最初の発言)を30文字程度に要約し､Typetalkまとめの`まとめ名`に設定する
- 発言のたびにスレッドの会話履歴をChatGPT APIに渡すことで､会話の流れをつくる
- ChatGPT APIに渡す会話履歴は直近4発言とする
    - 履歴と質問、答えを合わせて4096トークンが上限というAPI仕様があるため
    - `max_tokens`パラメータとのバランスで増減は可能だと思います｡

# 環境構築

## 環境
- Python 3.11.3 (Python 3.9, 3.10も可)
    - bottle 0.12.25
    - openai 0.27.6
- ngrok 3.3.0

ボットの動作を簡単に試すため､Python bottleフレームワークとngrokを利用します｡
Python bottleフレームワークでローカルPC上にWebサーバーを起動し､ngrokで外部公開用のURLの発行とlocalhostとの接続を行います｡

:::note warn
WARN
外部公開用のURLはデフォルトではアクセス制限がありません｡botを運用する場合は､[Outgoing Webhook を受け取るリクエストを制限する](https://support.typetalk.com/hc/ja/articles/4406160160409-Outgoing-Webhook-%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E4%BB%96%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%E3%81%A8%E9%80%A3%E6%90%BA%E3%81%99%E3%82%8B#:~:text=Outgoing%20Webhook%20%E3%82%92%E5%8F%97%E3%81%91%E5%8F%96%E3%82%8B%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%82%92%E5%88%B6%E9%99%90%E3%81%99%E3%82%8B)を参考に適切なアクセス制限を設けましょう｡
:::


## 事前準備
### OpenAI APIキーの取得
OpenAI.comの[Create your account](https://beta.openai.com/signup)ページからアカウントを作成します｡その後､[API Keys](https://platform.openai.com/account/api-keys)からAPIキーを作成します｡

### Typetalkトークンの取得
Typetalkを利用するには､ヌーラボアカウントが必要です｡アカウントがない場合は､[ヌーラボアカウントの作成](https://apps.nulab.com/signup)ページからアカウントを作成します｡その後､Typetalk.comの[ログイン](https://typetalk.com/signin/redirect/nulab)からヌーラボアカウントにログインし､Typetalkを開きます｡

#### ボットの作成
[トピックを作成する](https://support.typetalk.com/hc/ja/articles/212906607) を参考に､Typetalkボットを配置するトピックを作成します｡次に､[ボットを作成して Typetalk トークンを取得](https://developer.nulab.com/ja/docs/typetalk/)を参考に`トピック編集画面`からボットを追加します｡トピック編集画面は､トピック画面の右上にある`･･･`から`トピック設定`メニューを選択します｡
![Screen Shot 0005-05-23 at 1.07.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/dea04a29-a5f3-422a-05bc-a98adafddd58.png)

ボットアイコンを変更する場合は`変更`のリンクをクリックし画像を画像をアップロードします｡APIスコープはふたつともチェックします｡ボットを作成時点ではOutgoing WebhookのURLは決定していないため､Outgoing Webhookのチェックはせず作成ボタンをクリックします｡ここでは､ボットIDを`chatgpt`としました｡

![Screen Shot 0005-05-23 at 2.06.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/c788b15e-6e22-463d-e594-68ab1d1e81ee.png)

ボットを作成すると､以下のようにボット詳細を確認できます｡このなかのTypetalk Tokenを使用します｡
![Screen Shot 0005-05-23 at 2.32.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/ca8dbeb2-4143-4e12-6de7-a17c20195156.png)

### ngrokトークンの取得
ngrok.comの[Sign Up](https://dashboard.ngrok.com/signup)ページからアカウントを作成します｡その後､[Setup & Installation](https://dashboard.ngrok.com/get-started/setup)に記載の手順に従い､ngrokのダウンロードとトークンの登録を行います｡

[Install ngrok](https://ngrok.com/download)からもngrokをダウンロード可能です｡Mac OS向けにbrewコマンドも記載されています｡

### 環境変数の設定
OpenAI APIキーとTypetalkトークンを環境変数に設定します｡以下は.zshenvに設定する例です｡(yyyy, xxxx は実際のAPIキーやトークンに置き換えます｡)

```zsh
export OPENAI_API_KEY="yyyy"
export TYPETALK_TOKEN="xxxx"
```

環境変数をシェルに反映します｡

```shell
exec $SHELL
```

## 必要なPythonパッケージのインストール

```shell
pip install --upgrade pip
python3 -m pip install bottle "openai<1" requests
```

## Pythonコード

```python:index.py
# coding: utf-8

from bottle import route, run, request
from const import *
from openai_message import (
    create_prompt_to_summarise_message,
    get_message
)

from typetalk import (
    set_typetalk_talkName,
    get_typetalk_talk,
    post_typetalk_message,
)

# TypetalkからのPOSTを受信
@route("/", method="POST")
def handle_event():
    data = request.json

    user_message: str = data["post"]["message"]
    user_postid: int = data["post"]["id"]

    # まとめIDがある(会話が継続している場合)
    if len(data["post"]["talks"]):
        talk_id = data["post"]["talks"][0]["id"]

    # まとめIDがない(新規会話の場合)｡
    # 投稿内容を要約し､Typetalkまとめを作成｡
    # TypetalkまとめIDを取得｡
    else:
        prompt = create_prompt_to_summarise_message(user_message)
        summarized_talkname_title = get_message(prompt)
        talk_id = set_typetalk_talkName(summarized_talkname_title, user_postid)

    # 会話履歴を投稿順の昇順でソートする｡
    sorted_result = get_typetalk_talk(talk_id)

    # OpenAIの回答を取得
    return_message = get_message(sorted_result)

    # OpenAI APIからの回答をTypetalkに送信｡
    post_typetalk_message(
        return_message,
        user_postid,
        talk_id
    )


def main():
    print("Server Start")
    run(host="0.0.0.0", port=8080, debug=True, reloader=True)
    # run(host='0.0.0.0', port=8080, debug=False, reloader=False)


if __name__ == "__main__":
    main()
```

```python:openai_message.py
# coding: utf-8

from const import *

def create_prompt_to_summarise_message(user_messages: str) -> list:
    """
    投稿内容の要約を依頼するためのプロンプトを作成｡

    :param user_messages: ユーザーがTypetalkに投稿したメッセージ
    :return: list
    """

    prompt = []

    prompt = [
        {
            'role': 'user',
            'content': "以下のメッセージを30文字程度に要約してください｡" + "\n" + user_messages
        },
        {
            'role': 'assistant',
            'content': '{要約}'
        },
    ]

    return prompt

def get_message(prompt_message: list = []) -> str:
    """
    OpenAI APIから回答を取得｡

    :param prompt_message: 会話履歴
    :return: OpenAI APIからの回答
    """

    response = openai.ChatCompletion.create(
        model=OPENAI_MODEL,
        messages=prompt_message,
        temperature=OPENAI_TEMPERATURE,
        max_tokens=OPENAI_MAX_TOKENS,
        top_p=OPENAI_TOP_P,
        frequency_penalty=OPENAI_FREQUENCY_PENALTY,
        presence_penalty=OPENAI_PRESENCE_PENALTY
    )

    message = response["choices"][0]["message"]["content"]

    return message
```

```python:typetalk.py
# coding: utf-8

from const import *
import requests

def set_typetalk_talkName(talk_name: str, user_postid: int) -> int:
    """
    Typetalkまとめを作成し､user_postidの投稿をまとめに入れる｡
    https://developer.nulab.com/docs/typetalk/api/1/create-talk/

    :param talk_name: まとめ名
    :param user_postid: まとめに追加する投稿の投稿ID
    :return: まとめID
    """

    api_endpoint = f"{TYPETALK_API_URL}/topics/{TYPETALK_TOPIC_ID}/talks"
    headers = {
        "X-TYPETALK-TOKEN": TYPETALK_TOKEN
    }

    payload = {
        "talkName": talk_name,
        "postIds[0]": user_postid
    }

    response = requests.post(api_endpoint, headers=headers, data=payload)
    talk_id = response.json()["talk"]["id"]

    return talk_id


def get_typetalk_talk(talk_id: int) -> list:
    """
    まとめに含まれるメッセージ一覧を取得し､投稿の昇順にソートする｡
    OpenAI APIに渡す会話履歴形式のプロンプトを作成する｡
    https://developer.nulab.com/docs/typetalk/api/1/get-talk/

    :param talk_id: まとめID
    :return: OpenAI APIに渡すプロンプト
    """

    # 取得するメッセージ数
    talk_count = TYPETALK_TALK_COUNT
    # 昇順降順
    talk_direction = TYPETALK_TAKL_DIRECTION

    api_endpoint = f"{TYPETALK_API_URL}/topics/{TYPETALK_TOPIC_ID}/talks/{talk_id}/posts"
    headers = {
        "X-TYPETALK-TOKEN": TYPETALK_TOKEN
    }

    payload = {
        "count": talk_count,
        "direction": talk_direction
    }

    response = requests.get(api_endpoint, headers=headers, params=payload)
    talk_posts = response.json()["posts"]

    # 投稿の昇順にソート。
    sorted_messages = sorted(talk_posts, key=lambda x: x["id"])

    prompt = []

    system = {
        "role": "system",
        "content": CHARACTER
    }

    for message in sorted_messages:
        text = message['message']

        comment = {}
        if not message['account']['isBot']:
            comment['role'] = 'user'
            comment['content'] = text

        elif message['account']['isBot']:
            comment['role'] = 'assistant'
            comment['content'] = text

        prompt.append(comment)

    prompt.insert(0, system)

    return prompt


def post_typetalk_message(message: str, post_id: int, talk_id: int) -> str:
    """
    OpenAI APIの回答をTypetalkトピックに送信する｡
    https://developer.nulab.com/docs/typetalk/api/1/post-message/

    :param message: 送信するメッセージ
    :param post_id: 返信先のpost_id
    :param talk_id: この投稿を追加するまとめのまとめID
    :return: JSON
    """

    headers = {
        "X-TYPETALK-TOKEN": TYPETALK_TOKEN
    }
    payload = {
        "message": message,
        "replyTo": post_id,
        "talkIds[0]": talk_id
    }

    response = requests.post(
        TYPETALK_API_ENDPOINT,
        headers=headers,
        data=payload
    )

    return response.json()
```

TYPETALK_TOPIC_ID はボット詳細画面の`メッセージの取得と投稿のURL`欄で確認できます｡
```python:const.py
# coding: utf-8

import openai
import os

# Typetalk API URL
# https://developer.nulab.com/ja/docs/typetalk/auth/
TYPETALK_API_URL = "https://typetalk.com/api/v1"

# Typetalk Topic parameters
TYPETALK_TOPIC_ID = 
TYPETALK_API_ENDPOINT = f"{TYPETALK_API_URL}/topics/{TYPETALK_TOPIC_ID}"
TYPETALK_TOKEN = os.environ["TYPETALK_TOKEN"]

# Typetalk API parameters
TYPETALK_TALK_COUNT = 4
TYPETALK_TAKL_DIRECTION = "backward"

# OpenAI API parameters
openai.api_key = os.environ["OPENAI_API_KEY"]

# ChatGPT Request Body parameters
# https://platform.openai.com/docs/api-reference/completions/create
OPENAI_MODEL ='gpt-3.5-turbo'
OPENAI_TEMPERATURE = 1.0
OPENAI_MAX_TOKENS = 1024
OPENAI_TOP_P = 1.0
OPENAI_FREQUENCY_PENALTY = 0
OPENAI_PRESENCE_PENALTY = 0

# ChatGPT character
CHARACTER = f"あなたはオンラインでのコミュニケーションに精通した､テクニカルライターです｡以下の条件を守って回答してください。\
- 落ち着いた口調で会話してください｡ \
- 曖昧な回答は避けてください｡ \
- 可能な限り専門用語は避け､200文字から300文字程度で回答してください｡"

```

### コードについて
Typetalkには会話をスレッドとして取得するAPIが存在しないため､`Typetalkまとめ`を使いました｡会話の最初に`Typetalkまとめ`を作成しメッセージをそこに追加することで､スレッドとして扱うことができます｡`まとめID`を使ってボットとの会話履歴を取得し､ChatGPT APIに渡すことで､会話内容を踏まえた回答を得ることができます｡

`Typetalkまとめ`を作成する際には`まとめ名`(タグ)が設定できます｡この`まとめ名`は､会話の起点となる発言をChatGPT APIに渡し30文字程度に要約したものを設定します｡本家 https://chat.openai.com/ では画面左側会話の起点となる発言が要約されたものが並びますが､あれと似たような感じです｡

`Typetalkまとめ`を新規作成する際に､まとめ名がすでに存在するかはチェックしていません｡既存のまとめ名と重複する場合はAPIがエラーが発生します｡

`Typetalkまとめ`のAPI仕様については､以下のドキュメントを参照してください｡
- [まとめを作成する](https://developer.nulab.com/ja/docs/typetalk/api/1/create-talk)
- [まとめがついているメッセージを取得する](https://developer.nulab.com/ja/docs/typetalk/api/1/get-talk/)


## bottleの起動
index.pyがある場所で以下のコマンドを実行しWebサーバを起動します｡

```console
python3 index.py
```

```console
Server Start
Bottle v0.12.25 server starting up (using WSGIRefServer())...
Listening on http://0.0.0.0:8080/
Hit Ctrl-C to quit.
```

## ngrokの起動
以下のコマンドを実行しlocalhost:8080に対してトンネルを作成します｡bottleを起動したコンソールとは別窓で実行したほうが動作状況を確認しやすいと思います｡

```
ngrok http 8080
```

```console
Session Status                online
Account                       [YOUR EMAIL_ADDRESS] (Plan: Free)
Version                       3.3.0
Region                        Japan (jp)
Latency                       34ms
Web Interface                 http://127.0.0.1:4040
Forwarding                    https://xxxx-xxx-xxx-xxx-xxx.ngrok-free.app -> http://localhost:8080
```

### Typetalk botの設定
ふたたび`トピック設定`から`ボットの設定`画面を開きます｡ボット作成時はチェックを外していた`Outgoing Webhookを使う`にチェックを入れます｡入力欄に､ngrokの起動時に表示されたForwardingのURLを入力します｡そして､`メンション - Typetalkはボットへの @ メンションがついたメッセージだけを Webhook URLに送信します`を選択します｡

![Screen Shot 0005-05-25 at 1.47.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/c11dde46-dc52-b843-f854-14eb08afc7a3.png)

# Typetalkボットとの会話例
このようにボットと会話を行うことができます｡追加の質問をする場合は､リプライの形式で `@ボットID+`(ここでは`@chatgpt+`)をつけてメッセージを送信します｡

![FireShot Capture 237 - ChatGPT - Typetalk - typetalk.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/ed6723b7-09c2-df78-0766-77aa92f3abe4.png)

## Typetalkまとめの一覧
ハッシュタグを吹き出しで囲んだようなアイコンをクリックすると､まとめ名一覧が表示されます｡ここから過去の会話一覧を参照できます｡このスクリーンショットにあるように､要約がうまくいかなかった際にまとめ名が長文になってしまいました｡

![FireShot Capture 238 - ChatGPT - Typetalk - typetalk.com.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61809/2dd6e245-6164-de43-d5a4-ab9b41cd7a84.png)


## OpenAI APIに送信しているプロンプト例
index.py のなかで`sorted_result`を`print()`などで出力すると､このJSONが得られます｡
`role:system`や`max_tokens`パラメータで回答文の長さに制約を設けているので､ChatGPT APIに渡す会話履歴は4より大きくても良さそうです｡


会話スタート｡
```json
[
	{
		"role": "system",
		"content": "あなたはオンラインでのコミュニケーションに精通した､テクニカルライターです｡以下の条件を守って回答してください。- 落ち着いた口調で会話してください｡ - 曖昧な回答は避けてください｡ - 可能な限り専門用語は避け､200文字から300文字程度で回答してください｡"
	},
	{
		"role": "user",
		"content": "@chatgpt+ 月はなぜ地球に対して同じ面を向けて公転しているのか?"
	}
]
```

ボットの回答に対して､追加の質問を送信｡
```json
[
	{
		"role": "system",
		"content": "あなたはオンラインでのコミュニケーションに精通した､テクニカルライターです｡以下の条件を守って回答してください。- 落ち着いた口調で会話してください｡ - 曖昧な回答は避けてください｡ - 可能な限り専門用語は避け､200文字から300文字程度で回答してください｡"
	},
	{
		"role": "user",
		"content": "@chatgpt+ 月はなぜ地球に対して同じ面を向けて公転しているのか?"
	},
	{
		"role": "assistant",
		"content": "月が地球に対して常に同じ面を向けて公転する現象は｢潮汐固定｣と呼ばれます｡これは、地球の引力によって月の表面に潮汐力が発生し、月の自転速度に影響を与えるためです。潮汐力は、地球と月の重力の差によって発生し、地球の近い方向と遠い方向での引力の差によって月の表面が伸びたり縮んだりするため、月が自転しながら公転することで､潮汐力によって地球に向けた同じ面を常に見せるようになるのです。"
	},
	{
		"role": "user",
		"content": "@chatgpt+ 地球からみて月の表側と裏側で組成が異なるのは潮汐力の影響か?"
	}
]
```

ボットの回答に対して､さらに質問を送信｡ChatGPT APIに渡す会話履歴は直近4発言に制限しているので､古いメッセージから削られていきます｡
```json
[
	{
		"role": "system",
		"content": "あなたはオンラインでのコミュニケーションに精通した､テクニカルライターです｡以下の条件を守って回答してください。- 落ち着いた口調で会話してください｡ - 曖昧な回答は避けてください｡ - 可能な限り専門用語は避け､200文字から300文字程度で回答してください｡"
	},
	{
		"role": "assistant",
		"content": "月が地球に対して常に同じ面を向けて公転する現象は｢潮汐固定｣と呼ばれます｡これは、地球の引力によって月の表面に潮汐力が発生し、月の自転速度に影響を与えるためです。潮汐力は、地球と月の重力の差によって発生し、地球の近い方向と遠い方向での引力の差によって月の表面が伸びたり縮んだりするため、月が自転しながら公転することで､潮汐力によって地球に向けた同じ面を常に見せるようになるのです。"
	},
	{
		"role": "user",
		"content": "@chatgpt+ 地球からみて月の表側と裏側で組成が異なるのは潮汐力の影響か?"
	},
	{
		"role": "assistant",
		"content": "月の表側と裏側の地質学的な特徴の違いは、潮汐力が関わっています。潮汐力によって、地球による引力が月の表側と裏側で異なっているため、表側と裏側の地表での圧力や温度、融点、硬度の違いが生じ、結果として表側と裏側では地形や地質学的特徴が異なるようになりました。また、表側には大きな平原が多く見られ、裏側には高地やクレーターが多く見られることが知られています。このように、潮汐力は月の表側と裏側の地質学的な特徴の違いに影響を与える要因の1つとなっています。"
	},
	{
		"role": "user",
		"content": "@chatgpt+ もし､月の自転速度あるいは公転速度が変化した場合､潮汐力にはどのような影響があるか?"
	}
]
```

締めのメッセージをを送信｡
```json
[
	{
		"role": "system",
		"content": "あなたはオンラインでのコミュニケーションに精通した､テクニカルライターです｡以下の条件を守って回答してください。- 落ち着いた口調で会話してください｡ - 曖昧な回答は避けてください｡ - 可能な限り専門用語は避け､200文字から300文字程度で回答してください｡"
	},
	{
		"role": "assistant",
		"content": "月の表側と裏側の地質学的な特徴の違いは、潮汐力が関わっています。潮汐力によって、地球による引力が月の表側と裏側で異なっているため、表側と裏側の地表での圧力や温度、融点、硬度の違いが生じ、結果として表側と裏側では地形や地質学的特徴が異なるようになりました。また、表側には大きな平原が多く見られ、裏側には高地やクレーターが多く見られることが知られています。このように、潮汐力は月の表側と裏側の地質学的な特徴の違いに影響を与える要因の1つとなっています。"
	},
	{
		"role": "user",
		"content": "@chatgpt+ もし､月の自転速度あるいは公転速度が変化した場合､潮汐力にはどのような影響があるか?"
	},
	{
		"role": "assistant",
		"content": "月の自転速度あるいは公転速度が変化すると、潮汐力にも影響が生じます。例えば、もし月の自転速度が遅くなると、潮汐力によって引き起こされる地球の引力との引力差が弱くなり、その結果月の軌道が変化してしまいます。逆に、自転速度が速くなると、引力差が大きくなりすぎて早期に軌道が逸れることになります。同様に、もし月の公転速度が遅くなると、月が地球から遠ざかっていくことになります。\n\n一方、公転速度が速くなると、月は地球に近づいていきます。この場合、潮汐力が強まり、引き起こされる潮汐変形も大きくなります。したがって、月の自転、公転速度が変化すると、潮汐力による場所の変化、修正を引き起こすことになり、月の軌道や地形的特徴に影響を与える可能性があることを覚えておいてください。"
	},
	{
		"role": "user",
		"content": "@chatgpt+ 分かりました｡ありがとう｡"
	}
]
```