🧠

OpenAI が質問に答えてくれる Slackスラッシュコマンド の作り方

2022/12/22に公開

はじめに

(ちょっと前?)話題になった OpenAI の ChatGPT

そのベースモデルと言われているGPT-3のトレーニング済みモデルは公開されていて、APIから利用することが出来ます。

これを使って実験的な社内ツール(という名目)で簡単なアプリを作ってみました。

結果的に出来たのは

Slackのチャンネルメッセージ入力欄から、今回自作したコマンド「/query」で質問を投げると

openai-req.png

こんな風に返してくれるSlackアプリ。

openai-res.png

ゴール

  • Slackのスラッシュコマンドを使い
  • OpenAIのGPT-3モデル(Davinci)に質問を投げかけて
  • その回答をSlackに返す

というシンプルなSlackアプリを作ります。

今回、実行基盤は手軽にWebhookを使えるPipedreamを選びました。

使うサービス

  • Pipedream
    • HTTP/Webhook API

https://pipedream.com/apps/http

  • Slack App
    • Slash Commands

https://api.slack.com/interactivity/slash-commands

  • OpenAI API
    • Completion Endpoint

https://beta.openai.com/docs/api-reference/completions

流れ

おおまかに2つ

  • Slackから質問するフロー
  • その回答をSlackに返すフロー

です。具体的には

質問フロー

  • Slack → Pipedream → OpenAI

回答フロー

  • OpenAI → Pipedream → Slack

という形で、準備していきます。

Pipedream

まず先にエンドポイントとなるWebhook APIから用意しましょう。
(Pipedream自体の詳しい使い方は割愛)

  • HTTP / Webhook API
    外部に公開されたAPIエンドポイントを簡単に作成出来ます

https://pipedream.com/apps/http

ワークフローを新規作成

Triggerに「HTTP / Webhook」を選択して新しいワークフローを作成します。

pipe-webhook1.png

Event Dataに「Full HTTP Request」

HTTP Responseには、「Return a static response」を選択

pipe-webhook2.png

ヘッダーは、'Content-type: application/json'

ステータスは'200'で、Bodyはとりあえず以下を指定。

{
    "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "🤖< チョットカンガエテイマス..."
      }
    },
  ]
}

pipe-webhook3.png

Save & Continue をクリックすると、エンドポイントURLが表示。
このURLを、Slackアプリ作成時にRequest先として設定するので、控えておきます。

pipe-webhook4.png

Slack

今回はSlackアプリの

を使います。

Slash Commandsを使った質問時の流れは

  1. /command [なんかのメッセージ] のコマンド形式でSlackから質問
  2. Slackからエンドポイント(Pipedream)にメッセージがPOST
  3. Pipedream側で受け取ってメッセージを処理

結果の返し方は、Slash Commandsには2つのパターンがあります。

  1. POSTに対する「レスポンスとしてメッセージを返す」パターン

    • このとき、Content-Typeを指定せず(or text/plain)に返せば、テキストがそのままメッセージ本文になる
    • Content-Typeをapplication/json として、所定のJSONフォーマットで返すとメッセージの見た目をリッチに出来る(ブロックメッセージが使える
    • 3秒以内に応答する必要がある
  2. POST時のパラメータ 「response_url で指定されるURLに対してメッセージをPOSTする」パターン

    • response_url時限付きのWebhookエンドポイント
    • 時間のかかる処理の場合はこちら
    • 受付時のレスポンスは、200などを返しておく
      • このレスにも3秒ルール(3秒以内にレスポンスを返す)

今回の様に時間がかかる処理では、2のレスポンス用URL使った方法を採用します。

まずSlackアプリの作成

既存のアプリがあればそれを転用してもOK

なければ新規作成していきます。

https://api.slack.com/apps/new

アプリの作成

  • 任意の名前
  • 接続するSlackワークスペースを選択(アプリの追加先

slack-app.png

Slash Commands の作成

Settingメニュー > Basic Information から Slash Commands を選択

slack-app2.png

  • 任意のコマンド名
  • Request URL
    • 最初に作成したPipedreamのWebhook APIのエンドポイントURL
  • コマンドについての説明
  • コマンドの使い方

を入力して作成。

slack-app3.png

Slackワークスペース

Settingメニュー > Basic Informationに戻り、任意のワークスペースに作成したアプリをインストール。

slack-app4.png

疎通確認

ここまで出来たら一旦「Slackアプリ ↔ Pipedream」の疎通確認だけ行っていきます。
(一気に行く場合は飛ばしてOK)

先程決めたコマンドを、インストールしたSlackワークスペースから実行してみます。

適当なチャンネルか、自身のDM上から

/query hello

入力して送信。

うまくいくと、Slack側では以下の様なJSONテキストが返ってきます。

{
 "success": true,
 "info": "To customize this response, check out our docs at https://pipedream.com/docs/workflows/steps/triggers/#customizing-the-http-response"
}

Pipedream側では、以下の様な形での受信が確認出来ると思います。

pip-req.png

Pipedream側でのSlackアプリ認証

Pipedream側でリクエスト内容をチェックして、Slackアプリからのリクエストだけを処理するようにします。

具体的にはSlackアプリからのリクエストごとに、署名済みシークレットが送られてくるので、これを受けた側で検証します。

詳しくはこちら

Pipedreamのステップで検証します。

import crypto from 'crypto'

const isNullOrUndefined = (value) => {
  return (value === null || typeof value === 'undefined')
}

export default defineComponent({
  async run({ steps, $ }) {

    const now = Math.floor(Date.now() / 1000)
    const ts = steps.trigger.event.headers["x-slack-request-timestamp"]
    if( isNullOrUndefined(ts) ){
      $.flow.exit('Request Timestamp Error')
      return
    }
    // 発行から5分経過していたら蹴る
    const passed = Math.abs(now - ts)
    if( passed > 60 * 5 ){
      $.flow.exit('this request timestamps is more than 5 min. ' + `${passed} sec passed` )
      return
    }
    // Bodyをパラメータに
    const body = steps.trigger.event.body
    const bodyParams = new URLSearchParams(body).toString();
    // シグネチャー
    const sigBase = 'v0:' + ts + ':' + bodyParams
    // ハッシュ化
    const secret = process.env.SLACK_SIGNING_SECRET
    const sig = 'v0=' + crypto.createHmac('sha256', secret)
                      .update(sigBase)
                      .digest('hex');
    console.log(sig);
        // 送られきたシグネチャーと比較
    const slackSig = steps.trigger.event.headers["x-slack-signature"]
    if( isNullOrUndefined(slackSig) ){
      $.flow.exit('Slack Signature Error')
      return
    }
    if( sig === slackSig ){
      $.flow.exit('Verifying Signature Did Success')
      return steps.trigger.event      
    }
    $.flow.exit('this signature is invalid')
    return
  },
})

ざっくり流れ

発行タイムスタンプチェック

まず、タイムスタンプをチェックして5分経過してれば弾きます。

タイムスタンプはヘッダー

  • x-slack-request-timestamp

にあります。

const ts = steps.trigger.event.headers["x-slack-request-timestamp"]

シグネチャーのベースとなる文字列を作成

シグネチャーは

  • v0
  • タイムスタンプ
  • トークン以下のBody

を、:で連結した文字列です。

トークン以下のBodyは、pipedreamの場合、JSONにパースされているのでこれをクエリパラメータの形に戻しておきます。

const body = steps.trigger.event.body
const bodyParams = new URLSearchParams(body).toString();
token=XXXXXXXXXXX&team_id=XXXXXXXX&team_domain=XXXX&channel_id=XXXXXX&channel_name=directmessage&user_id=XXXXXXX&user_name=....

: で連結したシグネチャー文字列

v0:999999999999:token=XXXXXXXXXXX&team_id=XXXXXXXX&team_domain=XXXX&channel_id=XXXXXX&channel_name=directmessage&user_id=XXXXXXX&user_name=....

作成したシグネチャー文字列をハッシュ

Slackアプリの検証用シークレットを取得

SHA256ハッシュを作成

const sig = 'v0=' + crypto.createHmac('sha256', secret)
                    .update(sigBase)
                    .digest('hex');

最後に先頭に v0= を付与

v0=fcdfeaeed6caecbdc037e1f5cd6dedbccf0fd9bade6e7a130f6ca8302c070f0d

作成したハッシュと送信されてきたシグネチャーを比較

bcrypt が npm install error になって使えなかったので、普通に比較チェックしています。

シグネチャーはヘッダーの

  • x-slack-signature

から取得できます。

const slackSig = steps.trigger.event.headers["x-slack-signature"]

OpenAI API

OpenAI APIの、completionsエンドポイントを利用します。
質問などを投げかけると、指定したモデルが予測した結果を返します。

POST https://api.openai.com/v1/completions

https://beta.openai.com/docs/api-reference/completions/create

Completions API

今回は、一番頭が良いとされる「Davinci」モデルの2021年6月にトレーニングが完了した「text-davinci-003」を指定しました。

モデル詳細
https://beta.openai.com/docs/models/gpt-3

とりあえず試す

curl https://api.openai.com/v1/completions \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer {your api key}' \
  -d '{
  "model": "text-davinci-003",
  "prompt": "ハンターハンターっていつ終わるの?",
  "max_tokens": 4000
  }'

レスポンス

{
  "id": "cmpl-6KeDiOQ13ZVT5cikSXAH2DSQeSuOL",
  "object": "text_completion",
  "created": 1670379186,
  "model": "text-davinci-003",
  "choices": [
    {
      "text": "\n\n「HUNTER×HUNTER」の終了日は特に宣言されていません。最新話の続きがいつ始まるかも分かりません。",
      "index": 0,
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 24,
    "completion_tokens": 66,
    "total_tokens": 90
  }
}

Pipedreamに実装

Pipedreamでステップを作成して、OpenAIにPOSTする実装をしていきます。

import axios from "axios"

export default defineComponent({
  async run({ steps, $ }) {

    const endpoint = 'https://api.openai.com/v1/completions'
    const token = process.env.OPEN_API_SECRET
    const client = axios.create({
      baseURL: endpoint,
      headers: {
        'Content-type': 'application/json',
        'Authorization': ` Bearer ${token}`
      },
    })

    const prompt = steps.trigger.event.body.text

    const body = {
      "model": "text-davinci-003",
      "prompt": prompt,
      "max_tokens": 4000
    }   

    try{
      const resp = await client.post('', body)
      console.log(resp.data)
      return resp.data
    }catch(err){
      console.log(err)
      return null
    }
  },
})

Slackに回答を返す

回答送信先のURLは response_url にあります。

これは一時的なIncoming Webhookの様なもの?なので、認証情報などは特に必要ありません。

そこに対して、そのままメッセージをPOSTすればOK。

PipedreamでSlackへ回答する用のステップを追加。

import axios from "axios"

const makeMessageBody = (tite, prompt, reply) => {
  return {
          "text": tite,
          "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": `*「${prompt}」*`
            }
          },
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": `🤖< ガガガピガ...${reply}`
            }
          }
        ]
    }
}

export default defineComponent({
  async run({ steps, $ }) {

    const responseUrl = steps.trigger.event.body.response_url
    const client = axios.create({
      baseURL: responseUrl,
      headers: {
        'Content-type': 'application/json',
      },
    })
    // Result
    const results = steps.request_gpt_api.$return_value
    if( results === null ){
      try{
        await client.post(
          '',
          makeMessageBody(
            "❓🤖",
            steps.trigger.event.body.text,
            'ちょっと何言ってるか分かんないです',
          ),
        )        
      }catch(err){
        console.log(err)
      }
      $.flow.exit()
    }
    
    const reply = results.choices[0].text
    try{
      const resp = await client.post(
        '',
        makeMessageBody(
          "💡🤖",
          steps.trigger.event.body.text,
          reply,
        ),
      )
      console.log(resp)
    }catch(err){
      console.log(err)
    }
    return
  },
})

最終的に出来たステップ

Pipedreamの各ステップの全体はこちら

pip-whole.png

使ってみる

Slack側から実際にSlash Commandを使って試してみます。

コマンドは

  • /query [ここに質問クエリを書く]

の形式で書きます。

このコマンドをチャンネルメッセージ等に入力して送信すればOK

質問

/query アベノミクスって、つまりなんだっけ?

回答
openai-res-sample.png

おわりに

モデルが分からない物事をこちらが尋ねた際、それっぽい適当な単語を並べて、いかにも理解している体で返してくるケースが時々ありました。

openai-miss2.png

これ、もしこちらが全く知らない用語だったりすると、ウソと見抜けず結構困りますね😅

今は質問クエリをそのままOpenAIに投げていますが、ここの事前加工を上手にやるともっといい感じに出来そうです。

今回実験ツールとして作ってみましたが、もし社内などでガチ展開するなら、ドメイン用語などでファインチューニングする形になるのかなーと思いました。

面白そうなので機会があればやってみたい。

Fine-tuning
https://beta.openai.com/docs/api-reference/fine-tunes

Discussion