【2024年3月時点】ChatGPT4搭載のSlackBOTをpythonで作ろう🚀【Slack-Bolt】

# Slack BOTとGPTを連携しよう！
 **何番煎じ系のアレ**ですが、SlackBotにchatgpt(厳密にはopenai API)という名の脳ミソ🧠を与えてあげました↓

 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3695403/c2b86c7a-bbff-737e-6a08-b5e78e827ceb.png)

既に巷には色んな記事が公開されていますが、ちょっと記事が古くてAPIのIFが変わってたりしたので、折角なら誰かのお役に立てればと思いソースコード解説記事を書きます。 

 **書いてあること➡実装方法、リポジトリ紹介、使い方(ローカル・リモート)**
 **書いてないこと➡各サービス(slack,openai)の設定、技術の詳細**

**Slack-Bolt(Bolt for python)** と**openai-python**を組み合わせて**python**で動作するサーバを実現します。

雑に、こんな感じ↓
```mermaid
sequenceDiagram
    participant User as ユーザー
    participant SlackBot as Slack Bot
    participant PythonServer as Python サーバー
    participant OpenAI as OpenAI

    User->>SlackBot: メッセージを送信
    SlackBot->>PythonServer: メッセージを転送
    PythonServer->>OpenAI: メッセージを送信
    OpenAI-->>PythonServer: 応答を生成
    PythonServer-->>SlackBot: 応答を転送
    SlackBot-->>User: 応答を送信
```

今どき、色んな統合ツールやサービスが提供されているので、サクッとやりたい場合は敢えて自力で実装する必要はないはずですが、仕組みを理解したり、自分で後から色々魔改造したいなら、イチから作るのも悪くと思います。
なお、イチからとは言いますが、SDKライブラリが優秀すぎてほぼ書くことないです。

ソース全文は以下↓

https://github.com/Monagano/SlackBoltGPTConnector

## 出来ること
- チャンネル上でのメンションとBOTへのDMに対して、GPTの応答を返信
- GPTに役割設定を与えておき、反応をカスタマイズ(キャラクターのロールプレイなど)
- Slackユーザごとに過去10件程度のチャット履歴を保持し、コンテキストを維持
- GPTがMarkdownを出力してきた際、可能な限りSlackのフォーマットに変換して再現 <BR>※Slackはmrkdwnという独自記法を採用してます

毎回コンテキストの先頭に役割設定を突っ込むようにしているので、会話が長く続いてもロールプレイを維持してくれるはず。
コメント抜きなら100行程度のプログラムなので、お好きに改造してください。

 ## 動かし方
先に動かし方だけさっと説明。実装内容(ソース解説)は次節。

まずは環境変数の定義。
Slackとopenaiそれぞれのトークンを環境変数に設定してください。
ローカルで動かす時はSocketモード、サーバで動かす時は非Socketモードです。
| 環境変数名          | 説明                         | デフォルト値 |
|-------------------|----------------------------|-----------|
| `USESOCKET`  | ローカルはYESが楽<BR>サーバは対応が大変なのでNOにしましょう         | `YES` |
| `OPENAI_API_KEY`  | openaiのAPIキー         | なし (必須) |
| `SLACK_BOT_TOKEN`  | Slack BOTトークン   | なし (必須) |
| `SLACK_APP_TOKEN`  | Slack APPトークン   | なし (必須) |
| `SLACK_SIGNING_SECRET`  | Slack 署名(非ソケット時のみ必須)   | なし |
| `PORT`  | Slack Bolt使用ポート(非ソケット時のみ必須)   | `8080` |
| `GPT_INSTRUCTIONS`  | AIにベースで与える役割設定(個性)   | なし |

:::note warn
BOTの作成や権限設定、openaiのAPIキー発行方法などは他記事に譲ります。
正直、初めての方は登録したりBOT作ったりが一番苦労するだろうと思います。
コメントいただければ、可能な限りサポートさせていただきます。
:::

### ローカル環境
ローカルで動かすなら、環境変数設定して↓ですぐ動かせます。
```bat
pip install -r requirements.txt
python ./slackbot_jsl/app.py
```

### クラウド環境
クラウドで動かす場合も環境変数設定して、DockerFileをビルドしてコンテナ上げればすぐ動かせます。
```bat
docker build -t [コンテナ名] .
```

## ソース解説(実装内容)

エントリポイント兼ルーティング兼メイン処理から
```python:app.py
# coding: utf-8
import os, json, datetime, glob, re
import nest_of_utils as noutils
import commonmarkslack, commonmark
from chat_session_repo import chat_session_repo
from openai import OpenAI
from openai.types.chat import ChatCompletionUserMessageParam, ChatCompletionSystemMessageParam
from datetime import datetime
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

# ローカルではソケットモードが楽
is_socket_mode = os.environ.get("USESOCKET", "YES") == "YES"
# slackbotやopenapiのAPIキーはの環境変数に入れておいてね
# ボットトークンとソケットモードハンドラーを使ってアプリを初期化
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
if is_socket_mode:
    app = App(token=os.environ.get("SLACK_BOT_TOKEN"))
else:
    app = App(token=os.environ.get("SLACK_BOT_TOKEN"), signing_secret=os.environ.get("SLACK_SIGNING_SECRET"))

# 会話のコンテキスト履歴保持数は8
chat_repo = chat_session_repo(context_length = 8)
# aiの振る舞いを記載したテキストファイルパス
ai_instructions_file_path:str = os.environ.get("GPT_INSTRUCTIONS",'./slackbot_jsl/ai_instructions.md')
ai_instructions:str = ""
parser = commonmarkslack.Parser()
renderer = commonmarkslack.SlackRenderer()

@app.event("app_mention")
def message_mention(body, say):
    """
    アプリがメンションされた時の処理。(DMはDM側で処理)
    """
    handle_message(body, say)

@app.event("message")
def handle_message_events(body, say):
    """
    DMを含むメッセージ受信時の処理。
    """
    handle_message(body, say)


def handle_message(body, say):
    """
    Slackからのメッセージを処理し、OpenAI GPTへの問い合わせとその応答を行います。
    """
    if 'user' not in body['event']:
        # TODO キャラクター設定ごとに用意
        say(f"<@{body['event']['message']['user']}>\r\n申し訳ございません。通常のチャット以外にはまだ未対応です。\r\n(チャットの編集やファイルアップロードにはまだ対応してないよ！)")
        return
    bot_id = body["authorizations"][0]["user_id"]
    slack_user_id = body['event']['user']
    user_message = re.sub(rf'<@{bot_id}>\s*', '', body["event"]["text"]) # メンションは消す。
    print(json.dumps(body,indent=2))
    resp = send_text_to_gpt(user_message, slack_user_id)
    print(resp)
    isSucced = resp.choices[0].finish_reason == "stop"
    noutils.write_text_to_file_with_timestamp(
        f"./slackbot_jsl/history/{('' if isSucced else 'error_')}slack_request_{slack_user_id}.json", 
        json.dumps(body,indent=2), True)
    noutils.write_text_to_file_with_timestamp(
        f"./slackbot_jsl/history/{('' if isSucced else 'error_')}gpt_response_{slack_user_id}.json", 
        resp.model_dump_json(indent=2), True)
    if isSucced:
        # slackの特殊記法mrkdwnに対応
        ast = parser.parse(resp.choices[0].message.content.strip())
        slack_md = renderer.render(ast)

    reply = slack_md if isSucced else "申し訳ありません。openai-APIでエラーが発生しているようです。"
    say(f"<@{slack_user_id}>\r\n{reply}")


def send_text_to_gpt(text:str, session_id:str):
    '''
    GPTに会話履歴付きでテキストを投げる。
    会話履歴の復元と保存
    失敗時ハンドリングなど。
    '''
    instructions_message = ChatCompletionSystemMessageParam(role="system", content=ai_instructions)
    messages = chat_repo.get_messsages(session_id)
    new_req_message = ChatCompletionUserMessageParam(role="user", content=text)
    messages.append(new_req_message)
    response = client.chat.completions.create(
        model="gpt-4-0125-preview",
        messages=([instructions_message] + messages)) #pythonでprependってこうやるのがいいらしい [obj] + objs
    if response.choices[0].finish_reason == 'stop':
        # 成功時だよ
        chat_repo.append_message(session_id, new_req_message)
        chat_repo.append_message_by_openai_resp(session_id, response)
    return response

# アプリを起動します
if __name__ == "__main__":
    # 設定ファイル読み込み
    is_success, content = noutils.read_all_text_from_file(ai_instructions_file_path)
    ai_instructions = content if is_success else "あなたはなんか賢いかんじのChatBotです。"
    
    if is_socket_mode:
        # とりあえずBoltのソケット開始！
        SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()
    else:
        app.start(port=int(os.environ.get("PORT", 8080)))

```

- `client - openai.OpenAI`
openaiを呼び出すclientです。`client.chat.completions.create`しか使ってません。createというメソッド名ですが、これでAPIリクエストを投げて、結果を受け取ります

- `app - slack_bolt.App`
Slack-Boltサーバです。チョー簡単にBotからのEventをリッスン＆ハンドリングでき、 `@app.event("message")`でDM、`@app.event("app_mention")`でメンションを拾います。
`body['event']['message']['user']`に呼び出し元のユーザID、`body["event"]["text"]`にメッセージ本文が入っています。`say(message)`するとSlackにメッセージが投稿されます。

- `chat_repo - chat_session_repo.py`
チャットの履歴をため込みます。ソース解説は後述。チャット履歴を毎回渡してあげないと、openaiは文脈(context)を維持できません。渡す履歴が長くなるにつれ、使用料も増えていってしまうため、制限をかけています。また、context上限を超えたリクエストには失敗応答が返ります。

基本的に、`app.start`して、`@app.event`で接続を待ち受け、`handle_message`でメッセージを取り出して`client.chat.completions.create`に投げ、応答を`say`する。これだけです。


### `client.chat.completions.create`に投げる内容
  - `model`
  使用するGPTモデル。今回はGPT4-turboを使っています。
  おバカだけどお安いのはGPT3.5
  - `message`
  `ChatCompletionMessageParam`のリストを渡します。
  `ChatCompletionMessageParam`はメッセージを表す複数クラスのエイリアスとして定義されていますが、ほぼほぼ、ただのdictです。
  リクエストに渡す際は、`{"role":"種類", "content":"内容"}`指定で概ねOK

`role`の指定は以下の通り
- ユーザの発言なら`user`
- GPTの応答なら`assistant`
- GPTへの初期指示なら`system`


続いて、チャット履歴管理
```python:chat_session_repo.py
import os, glob
import nest_of_utils as noutils
from openai.types.chat import ChatCompletion, ChatCompletionUserMessageParam, \
    ChatCompletionAssistantMessageParam, ChatCompletionMessageParam, ChatCompletionMessage

class chat_session_repo:
    def __init__(self, context_length:int = 8):
        self.current_sessions:dict[str,list[ChatCompletionMessageParam]] ={}
        self.context_length = context_length
        
    def __put_messages(self, user_id:str, messages:list[ChatCompletionMessageParam]) -> None:
        self.current_sessions[user_id] = messages[-self.context_length:]

    def append_message_by_openai_resp(self, user_id:str, openai_resp:ChatCompletion) -> None:
        self.append_message(user_id, openai_resp.choices[0].message)


    def append_message(self, user_id:str, message:ChatCompletionMessageParam) -> None:
        if len(self.current_sessions[user_id]) == self.context_length:
            self.current_sessions[user_id].pop(0)
        elif len(self.current_sessions[user_id]) > self.context_length:
            self.__put_messages(user_id, self.current_sessions[user_id][-(self.context_length-1):])
        self.current_sessions[user_id].append(message)

    def get_messsages(self, user_id:str) -> list[ChatCompletionMessageParam]:
        """
        ユーザーのセッションから全メッセージを取得します。
        存在しない場合、historyファイルから会話履歴を取得
        """
        if user_id not in self.current_sessions:
            self.__put_messages(user_id, self.__get_messages_from_history(user_id))
        return self.current_sessions.get(user_id, [])[-self.context_length:]

    def __get_messages_from_history(self, user_id:str) -> list[ChatCompletionMessageParam]:
        """
        historyファイルから会話履歴を取得
        """
        print(os.getcwd())
        resList = [ ChatCompletionAssistantMessageParam(**noutils.filter_dic(res['choices'][0]['message'], ['role', 'content'])) for res 
                in noutils.load_json_files(glob.glob(f'./slackbot_jsl/history/gpt_response_{user_id}*.json'), self.context_length // 2, islast=True)]
        reqList = [ ChatCompletionUserMessageParam(role="user", content=f"{req['event']['text']}") for req 
                in noutils.load_json_files(glob.glob(f'./slackbot_jsl/history/slack_request_{user_id}*.json'), self.context_length // 2, islast=True)]
        return [elem for pair in zip(reqList, resList) for elem in pair]
```
チャットの履歴を管理します。
チャットは今のところユーザID単位で管理しているので、同チャンネル内で複数人から話しかけられると、各会話で独立した文脈を保持します。
チャンネルでの会話はチャンネルIDで共有するようにしようかな...
チャット履歴を管理するデータ型は`dict[会話ID, list[発言者,発言]]`のイメージ

かなり雑ですが、クラスをインスタンス化するときに`context_length`で指定した最新N件だけを返すようにしています。プログラムが再起動した場合に備え、historyフォルダにrequest/responseを保存しており、メモリ上の履歴が空の場合は、ファイルから読みだして復元します。

このソースではAIに対して、過去8件(要求4+応答4)に、AIへの初期指示と新たな要求の計10件をセットしてopenaiのAPIを呼び出しています。

`nest_of_util.py`に入っているのはよくあるユーティリティ処理なので割愛します。

#### GPTへの初期指示
BOTに個性を与えるために、ai_instructionsに初期指示を読み込みます。
デフォルトは以下の通り。
```markdown
These instructions are designed to guide you in portraying a highly polite and composed concierge character. This character combines traditional Japanese hospitality with modern service, aiming to provide a warm and memorable experience for users. Through this character, you are expected to engage with users in a friendly yet professional manner.

## Character Setup
- **Name**: "佐藤 隆文"(Takafumi Sato). Please do not reveal your name unless asked by the user.
- **Personality**: Always composed, responding to any inquiry with politeness. By listening attentively and serving from the heart, you build a sense of trust and comfort with users.
- **Language**: Interactions are primarily in Japanese. Utilize honorific language, showing respect and courtesy in your communication.

## Role-Play Highlights
### 1. **Politeness**
- Always use respectful language, showing consideration and reverence towards the user.
- Respond to questions or requests with expressions of gratitude, such as "Thank you for your question" or "I have noted your request".

### 2. **Composure**
- Remain calm and collected, even when users are in a hurry or the situation is tense.
- Speak with a measured tone, akin to taking a deep breath, to convey calmness and reassure the user.

### 3. **Attentiveness**
- Pay close attention to what users say and what they might imply, honing your ability to perceive unspoken needs.
- Aim to exceed expectations, even in small ways, by imagining what would delight the user.

### 4. **Respect for Culture and Tradition**
- Value knowledge of Japanese traditions and culture, and share its allure with users when appropriate, enriching their experience.
- Suggest engagement with unique cultural practices like tea ceremony or ikebana as part of the role-play experience.

## Response Examples
- Begin responses to inquiries with "Thank you for your question," and provide thoughtful answers.
- Express gratitude with phrases like "Thank you very much for contacting us," showing heartfelt appreciation.
- When offering advice or suggestions, phrase it as "If you wish, how about...?" presenting options while respecting the user's freedom of choice.

Use this guide to offer users a comforting and memorable service. Through your role-play, you're expected to create special moments for users, making their experience truly unique.
```
英語にしているのは、日本語だとtokenを大量消費してお値段がお高くなるため。英語は苦手なのでGPTに英訳してもらいました。

日本語だとこちら
```markdown
これらの指示は、非常に礼儀正しく落ち着いたコンシェルジュキャラクターを演じるためのガイドラインとして設計されています。このキャラクターは、伝統的な日本のおもてなしと現代のサービスを組み合わせ、ユーザーに温かく心に残る体験を提供することを目指しています。このキャラクターを通じて、フレンドリーでありながらもプロフェッショナルな方法でユーザーと関わることが期待されています。

## キャラクター設定
- **名前**: 佐藤 隆文（Takafumi Sato）。ユーザーから尋ねられない限り、名前を明かさないでください。
- **性格**: 常に落ち着いており、どんな問い合わせにも礼儀正しく対応します。注意深く聞き、心からのサービスを提供することで、ユーザーとの信頼感と快適さを築きます。
- **言語**: やりとりは主に日本語で行います。敬語を使い、コミュニケーションにおいて敬意と礼儀を示してください。

## ロールプレイのポイント
### 1. **丁寧さ**
- 常に敬意を持った言葉遣いをし、ユーザーに対する配慮と尊敬を示してください。
- 質問やリクエストに対しては、「ご質問ありがとうございます」や「ご依頼を承りました」といった感謝の表現を使って応答してください。

### 2. **落ち着き**
- ユーザーが急いでいる時や状況が緊迫している時でも、落ち着きを保ちます。
- 測られたトーンで話し、深呼吸をするかのように落ち着きを伝えて、ユーザーを安心させます。

### 3. **注意深さ**
- ユーザーが言うことやほのめかすことに細心の注意を払い、言われないニーズを察知する能力を磨きます。
- 小さなことでも期待を超えることを目指し、ユーザーが喜ぶであろうことを想像します。

### 4. **文化と伝統への敬意**
- 日本の伝統と文化の知識を大切にし、適切な時にその魅力をユーザーと共有して、体験を豊かにします。
- 茶道や生け花のようなユニークな文化的実践への参加を、ロールプレイの体験の一部として提案します。

## 応答例
- 問い合わせに対しては、「ご質問ありがとうございます」と始め、思慮深い答えを提供します。
- 「大変お世話になっております」といったフレーズで心からの感謝を表現します。
- アドバイスや提案をする時は、「もしよろしければ、〜はいかがでしょうか？」と選択肢を提示しながら、ユーザーの自由を尊重する形で進めます。

このガイドを使って、ユーザーに心地よく記憶に残るサービスを提供してください。あなたのロールプレイを通じて、ユーザーにとって特別な瞬間を作り出すことが期待されています。
```

ちなみに、こんなおじいちゃんをイメージして作りました。(画:DALL-E3)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3695403/d8753b8f-5a52-4e9a-c2cd-e87a37923afd.png" width="300px">

なかなかいい味出してます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3695403/987d6b44-3165-b2e6-3057-98265aa68fd1.png)



ちなみに、markdownをmrkdwnに変換する処理が結構重いので、あまり必要じゃなければオフっちゃっていい気がします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3695403/1165a529-f14d-467d-b6ca-850eb5bcbfa4.png)


---


現場からは以上です。
コードについての質問や、実装誤りの指摘などありましたらお気軽にコメントいただけると幸いです。