ChatGPT(GPT-4)に質問できるSlack BotをAWS Lambda(OpenAIライブラリなし)で作成してみた

## はじめに

n番煎じではありますが、タイトルの通りChatGPTに質問できるSlack botを作成しました、というものです。せっかくなのでGPT-4を使用します。

よく見かける事例では、OpenAI公式の[ライブラリ](https://github.com/openai/openai-python)を用いた実装例が多い印象ですが、それをあえて使わずにAWS Lambda(Python)標準のままで作成する方法をまとめました。

## 動機

1. 自分もChatGPTを使ったAIチャットSlack bot作りたいな〜。GPT-4で。
1. え、追加ライブラリが必要なの？ 調べてみると Python + OpenAIライブラリで作る事例が多数
1. ライブラリをLambdaにアップロードしたり、Lambda Layerを準備するの面倒だな...
1. AWS Lambdaを素の状態でサクッと作りたいな...

です。

## 構成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/9e7c44a0-e414-8746-d195-ba66552d909b.png)


### 前置き

Slackのレスポンスの仕様(3秒以内に応答せよ)に合わせるため、Lambdaを2段にしました。
<https://api.slack.com/interactivity/slash-commands#responding_to_commands>

これがないと、Slackからメッセージを送信した後、Lambdaから数回レスポンスが返ってきてしまうといった変な挙動になります。(初めてこの手のBotを作った人がハマる可能性がある事象です。)

Lambdaでの処理が短くすぐにSlackに応答できる場合には不要ですが、ChatGPTとのやり取り時間を考慮すると一旦先に返した方がよいです。

Lambda多段ではなくSQSを挟んだほうがいいという話がありますが、ここではシンプルにLambda多段にしました。
また、お手軽構築を優先として、過去のやりとりを記憶する仕組みは導入していません。

### 登場人物

- Slack : メッセージのやりとりを行う
- AWS
    - Lambda (1) : Slackメッセージの一次受けの役割、関数URLを発行して利用
    - Lambda (2) : Slack、ChatGPT間でのメッセージを捌く、いわば処理本体
- ChatGPT : 言わずもがなChatGPT、今回モデルはGPT-4を使用

### 処理の流れ

1. Slackからメッセージを送信
1. LambdaでSlackからのリクエストを受け、受け付けたことを即応答
1. 後続処理を別のLambdaに依頼
1. Slackのメッセージを元にChatGPTにメッセージ送信
1. ChatGPTからの応答を受信
1. ChatGPTからの応答を元にSlackに返答

## 作成

実際に作っていきます。

### Slackの設定 (前半)

- チャンネル作成

    SlackでChatGPTとのやり取り用のチャンネルを作成して、利用することとします。

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/774414eb-63bb-8e5a-3ffb-1669209db211.png)

- Slack apiのページでappを作成

    <https://api.slack.com/apps> -> Create New App -> From scratch

    - App Name : 任意のアプリ名
    - Pick a workspace to develop your app in: 追加したいSlackのワークスペースを選択

Create Appをクリックして作成します。
作成後、自動的に「Basic Information」ページに遷移するため、引き続き設定を行います。

- アプリ名、説明、アイコン設定

    「Basic Information」の画面で下までスクロールすると、Display Informationがあります。
    ここで、アプリ名、アプリの説明、アプリのアイコン等設定します。
    後で味気ないデフォルトアイコンを変えたくなった時、どこで変更するんだっけ？となりがちなので、ここで変えちゃいましょう。

    アイコン画像の条件は地味に引っかかるので、気をつけてください。
    (Icons must be squares between 512px by 512px and 2000px by 2000px, please!)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/ee01a2ef-6d36-d967-a6ea-a7716a28a6d0.png)

    変更したら、Save Changeを押します。

- 権限設定

    Basic Informationのページから、 -> Add features and functionality -> Permissions に移動します。
    OAuth & Permissionsのページに遷移するので、そのページ内のScope欄で以下の様に設定します。

   - app_mentions:read
   - chat:write
   - incoming-webhook

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/cd5984b2-1cf2-d5b1-5e53-653c89b9ea00.png)

- ワークスペースにインストール

    引き続き、Basic Informationのページから、 OAuth Tokens for Your Workspace欄でInstall to Workspaceを押します。
    以下の様に表示されるので許可します。
    
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/518bc956-28c8-32c0-4e5e-fef83e431358.png)

- Webhook URL の発行

    Slackの「Basic Information」の画面より、Building Apps for Slack -> 
 Incoming Webhooks に移動します。

    Webhook URLs for Your Workspaceの欄で、Add New Webhook to Workspaceをクリックします。

    ChatGPTとやり取りするチャンネルを選択して、許可します。

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/53aa7039-27df-2a17-e6a7-97fb5cdeea85.png)



- 作成したBotを、チャンネルに招待しておく

    ここで設定を忘れても、メッセージ送信時に@で呼び出した時にSlackが教えてくれます。

ここまでやったら、一旦Slackは離れます。

### AWSの設定 (前半)

#### IAM Role

以下のようなIAMロールを作成します。

- 信頼されたエンティティタイプ : AWS のサービス
- ユースケース : Lambda
- 許可ポリシー : AWSLambdaRole, AWSLambdaBasicExecutionRole 
- 名前(例) : LambdaInvokeRole

#### Lambda (1) の作成

以下のようなLambdaを作成します。

- 名前(例) : invokeSlackBot
- ランタイム : Python 3.11
- 実行ロール : 作成したロール (LambdaInvokeRole)
- 詳細設定
    - 関数URLを有効化
    - 認証タイプ : NONE
- (他はデフォルト)

コードを入力します。
    
```python
import json
import boto3
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def lambda_handler(event, context):

    logging.info(json.dumps(event))

    # 後で削除
    return json.loads(event["body"])["challenge"]

    client = boto3.client('lambda')
    response = client.invoke(
        FunctionName='chatGPT',
        InvocationType='Event',
        LogType='Tail',
        Payload= json.dumps(event)
    )

    return {
        'statusCode': 200,
        'body': json.dumps('OK')
    }
```

設定 -> 関数URLを確認し、コピーします。

### Slackの設定 (後半)

Slackに、Lambdaの関数URLを設定します。

- Slackの「Basic Information」の画面より、Building Apps for Slack -> Event Subscriptions に移動します。
- Enable Events を ONにします。
- Request URLに、先ほどコピーしたLambdaの関数URLを貼り付けます。
- 「Verified」と表示されるまで待ちます。

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/07ddaeb3-a2fb-9781-d0c9-81072c37907d.png)

- 下にスクロールし、Subscribe to bot events にてAdd Bot User Event をクリックし、「app_mention」を選択します。

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/0e88f925-518c-5644-62a5-9262999457d8.png)

- 忘れずに Save Change を押します。

### AWSの設定 (後半)

#### Lambda (1) の修正

先ほどLambdaを作成した際に以下のコードが含まれていましたが、これを削除します。

```python
...
        # 後で削除
        return json.loads(event["body"])["challenge"]
...
```

このコードは、Slackに関数URLを連携させて「Verified」と表示させるために一時的に必要だったものです。

#### Lambda (2) の作成

OpenAIのAPIキーが必要なため、払い出しておいてください。([参考](https://platform.openai.com/account/api-keys))


以下のようなLambdaを作成します。

- 名前(例) : chatGPT
- ランタイム : Python 3.11
- (他はデフォルト)

環境変数(設定 -> 環境変数)を設定します。

- MODEL : gpt-4
- OPENAI_API_KEY : (ご自身で払い出したAPI KEY)
- SLACK_WEBHOOK_URL : (ご自身で払い出したAPI KEY)

タイムアウトはデフォルトから延ばします。

- 設定 -> 一般設定 -> 5分くらい

コードを入力します。
    
``` python
import os
import json
import logging
from urllib import request, error
import re

logger = logging.getLogger()
logger.setLevel(logging.INFO)

chatgpt_url = "https://api.openai.com/v1/chat/completions"

# 設定系 (お好みで)
TEMPERATURE = 0.5
MAX_TOKENS = 2048
N = 1
TOP_P = 1
presence_penalty = 0.6
frequency_penalty = 0.0

system_role_defile="""
(お好きなロール設定を記載)
"""

def ask_chatgpt(prompt):
    
    api_key = os.environ["OPENAI_API_KEY"]
    
    model = os.environ["MODEL"]
    url = chatgpt_url

    output_text = ""

    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {api_key}"
    }

    payload = {
        "messages": [
            {"role": "system", "content": system_role_defile},
            {"role": "user", "content": prompt}
        ],
        "model": model,
        "n": N,
        "top_p": TOP_P,
        "temperature": TEMPERATURE
    }

    logger.info(f"url:{url}")
    logger.info(f"headers:{headers}")
    logger.info(f"payload:{payload}")
    req = request.Request(url, json.dumps(payload).encode(), headers, method="POST")

    try:
        with request.urlopen(req) as res:
            logger.info(f"status: {res.status} headers: {res.headers} msg: {res.msg}")
            response = json.loads(res.read().decode('utf-8'))
            
            logger.info(response)

            output_text = response['choices'][0]['message']['content'].strip()

    except error.HTTPError as e:
        logger.error(f"HTTPErrorが発生しました:{e}")
        if e.code == 429:
            output_text = f"申し訳ありません。429エラー({str(e)}) が発生しました。時間を置いて再度メッセージを送ってください。"
        else:
            output_text = str(e)
    except Exception as e:
        logger.error(f"想定外のエラーが発生しました:{e}")
        output_text = f"申し訳ありません。エラー({str(e)}) が発生しました。"

    return output_text


def reply_to_slack(channel, message, thread_ts=""):
    headers = { 'Content-Type': 'application/json; charset=utf-8' }
    
    url = os.environ["SLACK_WEBHOOK_URL"]
    
    if not message:
        message = "No Text"
    payload = {
        'text': message,
        "channel": channel,
        "icon_emoji": "",
        "thread_ts": thread_ts,
    }
    
    logger.info(f"Header: {headers} Payload: {payload}")
    
    req = request.Request(url, json.dumps(payload).encode("utf-8"), headers, method='POST')

    with request.urlopen(req) as res:
        logger.info(f"status: {res.status} headers: {res.headers} msg: {res.msg}")



def remove_at_symbol(string):
    return re.sub(r'<@.*?>', '', string)


def lambda_handler(event, context):
    logger.info(f"Received event: {json.dumps(event)}")
    
    body = {}
    if event.get('body'):
        body = json.loads(event['body'])

    if event.get('headers', {}).get('x-slack-signature'):
        # Slack からのアクセス時のみ対応
        channel = body['event']['channel']
        
        # メンション部分を削除
        text_args = body['event']['text']
        prompt = remove_at_symbol(text_args)
        
        logger.info(f"プロンプト: {prompt}")
        
        res = ask_chatgpt(prompt)
        
        thread_ts = body['event']['ts']
        
        logger.info(f"channel:{channel}, thread_ts:{thread_ts}")
        logger.info(f"res:{res}")
        
        reply_to_slack(channel, res, thread_ts)
    else:
        logger.info(f"Slackからの投稿ではないため終了します。")
```

## 完成品

動作確認をしてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/68a18d5a-5de7-0dd1-33c2-0ea90b32d42a.png)

<img width="400" alt="mojikyo45_640-2.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617410/3b193f83-2068-7887-a6fc-6a912125ebda.png">


あれ、[君は](https://qiita.com/fkdfkdfkd/items/7824673e6de4a906e8dd)...

以上です。