見出し画像

【初心者向け】AWS LambdaとOpenAI API(と、API gateway)を使い、スタンプを押すと翻訳してくれるSlack botを作る

こんにちは、浅井です。
大学院では自然言語処理を研究しており、インターン先でもLLMに関するプロジェクトに参加しています。

今回はインターン業務の傍らで作成した「Slack内で簡単に翻訳を行うbot」について紹介します。
言語モデルを利用した業務も研究も個人プロジェクトも中々うまく進まない中、しばしば現実逃避のためにこういうものを作って色々遊んでいます。
できる範囲でそれも発信していこう!と思い立ったので、こうしてnoteを書いています。

実際に業務に欲しい人や、そうでなくともOpenAI APIを使って遊んでみたい人は読んでもらえるとうれしいです。
「初心者向け」と書いてある通り、非エンジニアの人でも何とか作れると思います(僕もほとんどコード書けないので……)。非エンジニアの人、普段コードを書かない人もぜひ試してみてください。

実際に自分の所感チャンネルで翻訳botを運用している様子

背景

僕のインターン先には外国人のエンジニアさんがたくさんいます。
そのため仕事に関する場面、Slackやミーティングにおける公用語は基本的に英語です。

ほとんどの場合、英語のみの利用で問題ないのですが、以下のような場合に自動翻訳してほしい、と思うことがあります。

  1. ブログなどのために用意する文章は日本語のため、外国人エンジニアが理解できない

  2.  仕事に関係のない所感チャンネルに落書き程度に書いている日本語の内容を知りたい

  3. 英語が苦手でたまに理解できてるか怪しいインターン生がいる🙃

etc…

そのため、サクッとSlack上の日本語↔️英語を「その場で」翻訳できるような簡易なSlack botを実装してみました。

使ったもの

OpenAI API(gpt-4)

今回の翻訳はgpt-4に行ってもらいました。
OpenAI APIを使用した理由は、クオリティとプライバシー保護の観点からです。クオリティは言わずもがな、プライバシーポリシーについてもある程度真っ当であるように思えます。

※とはいえ、送信するデータは慎重に選択する必要があります。業務に関係のないおしゃべりチャンネル等から試していくことをお勧めします。

AWS Lambda

関数をここで作成しました。サーバーレスで従量課金制なので、小さな社内ツールの作成などに便利。

Amazon API Gateway

SlackのWebhookをAPI Gatewayに接続し、API GatewayのイベントをトリガーにLambda関数を起動させるようにしました。

Slack API

Slackbotを作れます。

実装内容


こんなかんじです


⓪AWSアカウント、OpenAI API用のアカウントを作成

AWSのアカウントはすでに作成されているものとします、すみません。
初めての人は下記ページに従ってアカウントの作成を行なってください。

次にOpenAI APIの取得についてです。
下記のページからサインアップし、APIキーを作成してください。

アカウント作成後、こちらのAPI keysから新しいシークレットキーを作成します。作成したシークレットキーは初回にちゃんとコピーしてください。

コピーせずに暗記するとセキュリティリスクが減ります


①Lambda関数を作成する

AWS Lambdaを開き、「関数」ページより、「関数の作成」をクリックします。

関数名は好きにして大丈夫です。
ランタイムは新しいpythonのバージョンにすれば良いと思います。
それ以外は一旦そのままで「関数の作成」をクリックします。
問題なければ、無事Lambda関数が作成できるはずです。

作成が完了したら、いくつかの設定を行いましょう。
まず、Lambda関数の「設定」→「環境変数」ページより、先ほどOpenAI APIのページで取得したシークレットキーを格納してください。

また、lambdaの「一般設定」でのタイムアウト時間を伸ばしておきましょう。
OpenAI APIからのレスポンスはそれなりに時間を要すため、デフォルトの3秒ではタイムアウトしてしまいます。

とりあえず1分0秒にしました


②Lambdaレイヤーを作成し、OpenAIのpythonパッケージを使用する

次はOpenAIのpythonパッケージを導入するためのレイヤー作成です。
(お使いの環境によってはここで躓くことが多いので、はまりどころを末尾に記載しておきます。)

openaiにpythonからリクエストを送るには、OpenAIのpythonパッケージをあらかじめインストールしておく必要があります。
しかしLambdaの中で直接 pip install openai することはできません。
そのため、一旦ローカルでpythonパッケージをダウンロードしておき、それをLambdaレイヤーとしてアップロードする必要があります。

まずはローカルで以下のコマンドを実行し、Lambdaレイヤーのzipファイルをダウンロードします。

mkdir python
pip install -t ./python openai
zip -r openai.zip ./python

最近はfastapiのバージョンのせいでエラーが起きるらしい?ので、こちらにしておいた方が安心かもしれません。(2024/01現在)

mkdir python
pip install -t ./python openai fastapi==0.99.0
zip -r openai_fastapi.zip ./python

その後、上記のコマンドで作成したopenai.zipをLambda上にアップロードし、Lambdaレイヤーを作成します。

「アップロード」より、先ほど作成したzipファイルをアップロードしましょう。
レイヤーの名前は好きにつけて大丈夫です。
アーキテクチャやランタイムは作成したLambda関数と同じものを選択してください。

レイヤーの作成が完了したら、先ほど作ったLambda関数を再度開き、「レイヤーを追加」から作成したレイヤーを関数に追加します。

これでopenaiのライブラリが入ったLambdaレイヤーの作成・追加は完了です。レイヤーが適切に動作しているか確認しましょう。

Slackとの接続等は後回しにし、LambdaでOpenAIに対するAPIリクエストが成功するかだけを確かめます。そのためのコードを以下に記しておきます。

import os, sys, json
from openai import OpenAI

client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY']
)
content = '''
Please translate the text from user. 
Do not output any words other than what is translated. 
If the given text is in Japanese, translate it into English. 
If the given text is in English, translate it into Japanese.
'''
completion = client.chat.completions.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": content},
                {"role": "user", "content": "今日はいい天気ですね!"}
            ]
        )
ans = completion.choices[0].message.content
print(ans)
def lambda_handler(event, context):
    return ans

コードを書き終わったら、「Deploy」→「Test」で動作をテストします。
test eventを設定する必要がありますが、まだ使わないのでデフォルトのままで保存して大丈夫です。

無事に翻訳ができていたら成功です。
上手くいかない場合、レイヤーからopenaiのライブラリを適切に読み込めていないかもしれません。


③(とりあえず)チャンネルに手動でポストしてくれるSlack botを作成する

無事にLambdaでOpenAI APIが動作することを確認できました。
次はLambda→Slackに情報を送り、マニュアルでbotを動かすことを目指します。

まず、Slackで新しいappを作成します。

Create New Appをクリックし、今回のためのSlack botを作成します。

From scratchを押してください
導入したいワークスペースを選択してください

作成したら、次はpermissionの設定を行います。
Slack APIの左サイドバーから、OAuth & Permissionsページを開きます。

Scopesを確認し、「Add an OAuth Scope」よりchat:writeを追加します。

次にInstall to Workspaceを押してワークスペースとの接続を行います。

接続が成功したら、「xoxb-…….」のようなBot User OAuth Tokenが取得できます。

早速、このTokenを用いてSlackに投稿を行えるか試してみましょう。
Lambdaの環境変数ページを開き、手に入ったBot User OAuth TokenをSLACK_TOKENとして追加します。

openai_api_keyと同じ場所


その後、投稿したいSlackのチャンネルにあらかじめSlackbotを追加しておきます。
適当に該当するbotをメンションして送信したらチャンネルに追加できます。
絶対にこれ以外のやり方がある気がします。

今回の場合、botがチャンネルに追加されてないと翻訳できません

また、該当チャンネルのチャンネルIDが必要なため、こちらも取得します。
チャンネルIDはSlackでチャンネルを右クリックし、「コピー」→「リンクをコピー」を押せば取得できます。URLの最後の9~12文字くらいの文字列がチャンネルIDです。(https://xxxxxxx.slack.com/archives/……..  の archives/ 以降の部分)

ここからリンクがコピーできます

これらの情報を用いて、lambda_function.pyを更新し、チャンネルに投稿してみましょう。

※下記の関数を実行するために、requestsというpythonライブラリが必要なので、openaiのライブラリと同様にレイヤーを作成し追加してください。

以下がテスト用コードになります。

import os, sys, json
from openai import OpenAI
import requests

slack_token = os.environ["SLACK_TOKEN"]

def send_message_to_slack(channel, message,token):
    url = "https://slack.com/api/chat.postMessage"
    headers = {
        "Authorization": "Bearer " + token,
        "Content-Type": "application/json"
    }
    payload = {
        "channel": channel,
        "text": message,
    }
    response = requests.post(url, data=json.dumps(payload), headers=headers)
    return response.json()

client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY']
)
content = '''
Please translate the text from user. 
Do not output any words other than what is translated. 
If the given text is in Japanese, translate it into English. 
If the given text is in English, translate it into Japanese.
'''
completion = client.chat.completions.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": content},
                {"role": "user", "content": "今日はいい天気ですね!"}
            ]
        )
ans = completion.choices[0].message.content

def lambda_handler(event, context):
    channel = "ここに取得したチャンネルのIDを入れてください"
    send_message_to_slack(channel, ans, slack_token)
    
    return ans


先ほどと同様にtestを実行します。
Slackのチャンネルに意図通りにbotの投稿が行われていたら成功です!

成功したらこんな感じに投稿が行われます

これでLambda→Slackの送信に関しては完了です。
ここまででひと段落なので、お茶を飲みます。🍵


④API Gatewayを構築し、Slackのユーザーのアクションを取得できるようにする

一つ前の項で、Lambda→Slackへの送信は行えることが確認できました。
次は、SlackでのユーザーのアクションをLambdaに通知させる仕組みを作ります。(スタンプを押したタイミングで実行させる必要があるためです)

SlackのWebhookをAPI Gatewayに接続し、API GatewayのイベントをトリガーにLambda関数を起動させるようにしました。

今回の場合のやり方を説明します。
まず、API Gatewayを開き、「APIを作成」から新しいAPIを作成します。

REST APIを構築
エンドポイントタイプは「リージョン」のままで良いはずです

その後、リソースを作成します。

左メニューバーから「リソース」を開き、「リソースの作成」を選択

次にメソッドを作成します。
作成したリソースを開き、「メソッドを作成」を選択してください。

メソッドタイプは「POST」を選択し、統合タイプは「Lambda関数」を選択してください。Lambda関数の項では、作成したLambda関数を選択します。

それらの準備が終わったら、APIをデプロイします。

Slackからはapplication/x-www-form-urlencoded形式でデータが送られてくるので、これをjson形式に変換しておきます。
該当するPOSTについて、統合リクエストの設定を開き、マッピングテンプレートを編集します。

(こちらのページをそのまま使いました。ありがとうございます……)

application/x-www-form-urlencoded

## convert HTML POST data or HTTP GET query string to JSON

## get the raw post data from the AWS built-in variable and give it a nicer name
#if ($context.httpMethod == "POST")
 #set($rawAPIData = $input.path('$'))
#elseif ($context.httpMethod == "GET")
 #set($rawAPIData = $input.params().querystring)
 #set($rawAPIData = $rawAPIData.toString())
 #set($rawAPIDataLength = $rawAPIData.length() - 1)
 #set($rawAPIData = $rawAPIData.substring(1, $rawAPIDataLength))
 #set($rawAPIData = $rawAPIData.replace(", ", "&"))
#else
 #set($rawAPIData = "")
#end

## first we get the number of "&" in the string, this tells us if there is more than one key value pair
#set($countAmpersands = $rawAPIData.length() - $rawAPIData.replace("&", "").length())

## if there are no "&" at all then we have only one key value pair.
## we append an ampersand to the string so that we can tokenise it the same way as multiple kv pairs.
## the "empty" kv pair to the right of the ampersand will be ignored anyway.
#if ($countAmpersands == 0)
 #set($rawPostData = $rawAPIData + "&")
#end

## now we tokenise using the ampersand(s)
#set($tokenisedAmpersand = $rawAPIData.split("&"))

## we set up a variable to hold the valid key value pairs
#set($tokenisedEquals = [])

## now we set up a loop to find the valid key value pairs, which must contain only one "="
#foreach( $kvPair in $tokenisedAmpersand )
 #set($countEquals = $kvPair.length() - $kvPair.replace("=", "").length())
 #if ($countEquals == 1)
  #set($kvTokenised = $kvPair.split("="))
  #if ($kvTokenised[0].length() > 0)
   ## we found a valid key value pair. add it to the list.
   #set($devNull = $tokenisedEquals.add($kvPair))
  #end
 #end
#end

## next we set up our loop inside the output structure "{" and "}"
{
#foreach( $kvPair in $tokenisedEquals )
  ## finally we output the JSON for this pair and append a comma if this isn't the last pair
  #set($kvTokenised = $kvPair.split("="))
 "$util.urlDecode($kvTokenised[0])" : #if($kvTokenised[1].length() > 0)"$util.urlDecode($kvTokenised[1])"#{else}""#end#if( $foreach.hasNext ),#end
#end
}
コンテンツタイプはapplication/x-www-form-urlencodedにしてください。

以下のようにマッピングテンプレートを設定しておけば、Slackからのリクエストが以下のようなjson形式で取得できるようになるはずです。

{'token': 'QHIqUgKBA3VDT0mkvc3IgaKX', 'challenge': 'lPwXCkXaPyri14Q3sIaWUcIjZXTPioYUTrvis7ivF0TZ606SxZ0U', 'type': 'url_verification'}


Slack APIのページを開き、ステージのURLを登録しましょう。
前項で作成したステージではURLが生成されているので、こちらをコピーします。

その後、Slack APIからEvent Subscriptionsを開きます。

Request URLの欄に先ほど取得したステージのURLを入れると、Slack側からテストのリクエストが送信されます。

このテストでは、受け取ったevent内のchallengeの項目をそのまま返すことが求められます。
そのため、作成していたLambda関数のlambda_handler部分を一時的に以下のようにしておきます。

def lambda_handler(event, context):
    send_message_to_slack(channel, ans, slack_token)
    print(event)
    return event['challenge']

上記のようにVerifiedの✅がついたら、接続は成功です。


⑤スタンプに反応し、自動で返信するSlack botを作成する

満を持してSlack botを完成させます。

まずは、botがスタンプを検知するよう、Slack側で許可を付与します。
Subscribe to bot eventsより、reaction_addedを追加して、Save Changesします。
reinstallを求められると思うので、eventは追加するたびにワークスペースにbotを再インストールしましょう。

上記の操作で、ユーザーの誰かがスタンプを押すと、Slack APIからAPI Gatewayを通してLambdaに情報が送られるようになりました。

また、スタンプに対するリアクションの情報だけでは、翻訳する本文を閲覧できないので、OAuth&PermissionsのページからScopesを新たに追加します。
以下の画像のようなスコープの付与を行なってください。

こちらの権限が必要なはずです
import os, sys, json
from openai import OpenAI
import requests

slack_token = os.environ["SLACK_TOKEN"]

def send_message_to_slack(channel, message,token,thread):
    url = "https://slack.com/api/chat.postMessage"
    headers = {
        "Authorization": "Bearer " + token,
        "Content-Type": "application/json"
    }
    payload = {
        "channel": channel,
        "text": message,
        "thread_ts": thread
    }
    response = requests.post(url, data=json.dumps(payload), headers=headers)
    return response.json()

client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY']
)

def get_message_text(event):
    # Slack APIトークン
    token = slack_token

    # メッセージの情報を取得するためのリクエストパラメータ
    channel = event["event"]["item"]["channel"]
    message_ts = event["event"]["item"]["ts"]

    # Slack APIを使ってメッセージの内容を取得
    url = "https://slack.com/api/conversations.replies"
    headers = {"Authorization": "Bearer " + token}
    params = {"channel": channel, "ts": message_ts}
    response = requests.get(url, headers=headers, params=params)
    if response.status_code == 200:
        message_data = response.json()
        print("message_data: \n\n\n", message_data)
        if message_data["ok"] and message_data["messages"]:
            message_text = message_data["messages"][0]["text"]
            print("message_text: \n\n\n",message_text)
            return message_text

def run_gpt(text):
    content = '''
Please translate the text from user. 
Do not output any words other than what is translated. 
If the given text is in Japanese, translate it into English. 
If the given text is in English, translate it into Japanese.
'''

    completion = client.chat.completions.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": content},
                {"role": "user", "content": text}
            ]
        )
    ans = completion.choices[0].message.content
    return ans
    

def lambda_handler(event, context):
    if event["event"]["reaction"] != "white_check_mark":
        return
    channel = event["event"]["item"]["channel"]
    text = get_message_text(event)
    ans = run_gpt(text)
    thread = event["event"]["item"]["ts"]

    send_message_to_slack(channel, ans, slack_token,thread)
    return

上記のコードをdeployし、botが入っているSlackチャンネルでスタンプを押したら、その翻訳が投稿されるはずです。(✅スタンプの時のみ反応するようにしています)

これでほとんど完成です。
しかし、もう一点問題があります。

Slack botは3秒以内にリクエストが返ってこなかった場合、リクエストが何度も行われてしまうようです。
そのため、Slack API側からの複数回のリクエストを防止しなければ、以下のようになってしまいます。

上記のコードだとこうなります、たぶん

Lambdaの設定から同時実行周りをいじれば解決するかな、と思ったのですが、上手くいきませんでした(知っている人いたら教えてください……)

これを予防する方法としては、

①S3やDBなどに受け取った文章を保存し、重複がないかチェックする。
②Lambda関数を二つに分ける。(イベントを受け取るものと、処理をするもの)
③ヘッダーの X-Slack-Retry-Numを確認し、再送のものだけを無視する

などを考えましたが、今回は①を利用してみたいと思います。
翻訳ではあまり有り難みが無いですが、ユーザーの質問と応答については今後のモデル改善のために保存しておきたいと考えたからです。

今回はDynamoDBを使い、その中にリクエストを保存し、重複を確認することにします。
DynamoDBを開き、「テーブルの作成」を押してください。

timestampをパーティションキーにし、テーブルを作成します。

また、LambdaからDynamoDBへのアクセス権限も追加しておいてください。

「設定」→「アクセス権限」より、青文字のロール名をクリックします。

現在のロールの権限について、「許可を追加」→「ポリシーをアタッチ」より、"AmazonDynamoDBFullAccess"を追加してください。

これでDynamoDBにアクセスする許可を与えることができました。
Lambda関数が実行されるたびにDynamoDB内に存在するかを確認した上で、

- 初めてのリクエストの場合、dynamodbに追加してから実行
- すでに存在する場合、そのまま実行終了

するような実装を行います。
(短時間に大量にDynamoDBを操作すると料金が大変なことになります、大人数のSlackワークスペースで使用する際にはご注意ください)

今回、最終的なコードは以下のようになりました。

import os, sys, json
from openai import OpenAI
import requests

import boto3
from boto3.dynamodb.conditions import Key
dynamodb = boto3.resource('dynamodb')

slack_token = os.environ["SLACK_TOKEN"]

def send_message_to_slack(channel, message,token,thread):
    url = "https://slack.com/api/chat.postMessage"
    headers = {
        "Authorization": "Bearer " + token,
        "Content-Type": "application/json"
    }
    payload = {
        "channel": channel,
        "text": message,
        "thread_ts": thread
    }
    response = requests.post(url, data=json.dumps(payload), headers=headers)
    return response.json()

client = OpenAI(
    api_key=os.environ['OPENAI_API_KEY']
)

def get_message_text(event):
    # Slack APIトークン
    token = slack_token

    # メッセージの情報を取得するためのリクエストパラメータ
    channel = event["event"]["item"]["channel"]
    message_ts = event["event"]["item"]["ts"]

    # Slack APIを使ってメッセージの内容を取得
    url = "https://slack.com/api/conversations.replies"
    headers = {"Authorization": "Bearer " + token}
    params = {"channel": channel, "ts": message_ts}
    response = requests.get(url, headers=headers, params=params)
    if response.status_code == 200:
        message_data = response.json()
        print("message_data: \n\n\n", message_data)
        if message_data["ok"] and message_data["messages"]:
            message_text = message_data["messages"][0]["text"]
            print("message_text: \n\n\n",message_text)
            return message_text
            
def check_translate_dict(event,thread,text):
    table = dynamodb.Table('ここに作成したDynamoDBのテーブル名を入れてください')
    response = table.get_item(
        Key={
            'timestamp': thread
        }
        )
    print("responce: ",response)
    if 'Item' in response:
        print("already answered")
        return False
    else:
        table.put_item(
        Item={
            "timestamp": thread,
            "contents" : str(event),
            "text" : text
        }
        )
        return True

def run_gpt(thread,text):
    content = '''
Please translate the text from user. 
Do not output any words other than what is translated. 
If the given text is in Japanese, translate it into English. 
If the given text is in English, translate it into Japanese.
'''

    completion = client.chat.completions.create(
            model="gpt-4",
            messages=[
                {"role": "system", "content": content},
                {"role": "user", "content": text}
            ]
        )
    ans = completion.choices[0].message.content
    
    table = dynamodb.Table('ここに作成したDynamoDBのテーブル名を入れてください')
    table.update_item(
        Key={
            'timestamp': thread  # 既存アイテムを特定するためのキー
            },
            UpdateExpression='SET answer = :val',  # 追加する属性とその値
            ExpressionAttributeValues={
                ':val': ans
                })
    return ans
    

def lambda_handler(event, context):
    if event["event"]["reaction"] != "white_check_mark":
        return

    channel = event["event"]["item"]["channel"]
    text = get_message_text(event)
    thread = event["event"]["item"]["ts"]
    check = check_translate_dict(event,thread,text)
    
    
    if check == False:
        return
    
    ans = run_gpt(thread,text)
    send_message_to_slack(channel, ans, slack_token,thread)
    print(event)
    return

このコードをデプロイ後、Slack上でスタンプを押し、数秒後にそれに対してbotが返信を送っていたならば成功です!

完成!
周り道だったらごめんなさい!
プロンプトを調整すれば翻訳以外の用途にも使えるので、色々と試してみてください。

起こりがちなエラー

以下にまとめています、他に何かあれば連絡ください。


今後の展望

スタンプに反応するようにしましたが、メンションへのリプライやDMへの送信等も行えます。用途に合わせて色々と調整するといいと思います。

今回はスレッドにリプライを送るという形にしましたが、スレッドでの会話が遮断されることが不満だという意見もありました。
本人しか見ないのであれば、代わりに翻訳内容をDM送信する、等のオプションも検討すると、体験がより良くなるように感じています。

その他、何か不都合な部分やご意見等ありましたらご連絡ください。

We’re hiring!

株式会社Yoiiでは一緒に働く仲間を募集しています。

データエンジニアやソフトウェアエンジニアをはじめ、様々な職種での募集を随時行っていますので、興味がある方は上記の求人一覧をご覧ください。


感想

社内で英語わかんないの俺だけだから9割俺しか使ってない。







この記事が気に入ったらサポートをしてみませんか?