🚀

Slack Bot に GPT-4V API を搭載する

2023/11/09に公開

NOT A HOTEL の Slack では GPT-4 の API を使った Slack Bot が動いています。これは以前、アイディアソンの司会進行をしながら、その裏で CTO とハッカソンを行い開発したものです。[1]

この時はまだ Firebase を使ってボットを運用していたのですが、ご存知の通り Cloudflare Workers が盛り上がってきている(早い、安い、開発体験 🚀)のでそこに移しました。

そして先日、ついに OpenAI から GPT-4V の機能を利用できる Vision API がリリースされました。この機能のおかげで GPT-4 は画像を理解することができるようになり、さらに活用の幅が広がりそうです。

その練習を兼ねて、Slack Bot に Vision API の機能を追加してみました。この記事では Vision API と Slack との連携をどうやったのか、一つの事例を紹介できればと思います!

Vision API の使い方

まず、この API へのリクエストはどのように行うのかを復習しましょう。画像の情報を GPT-4 へどう教えるのか curl の例をここに貼ります。

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4-vision-preview",
    "messages": [
      {
        "role": "user",
        "content": [
          {
            "type": "text",
            "text": "What’s in this image?"
          },
          {
            "type": "image_url",
            "image_url": {
              "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"
            }
          }
        ]
      }
    ],
    "max_tokens": 300
  }'

今までは rolecontent の情報を与えるだけでしたが、この API では content が配列となり、構成される要素としてテキスト以外に image_url というものが出てきました。

image_url に許容できるものは 2 つです。

  1. 画像の URL
  2. data:image/jpeg;base64,{base64_image} といった base64 形式

渡せる画像の形式は PNG (.png), JPEG (.jpeg and .jpg), WEBP (.webp), そしてアニメーションのない GIF (.gif) です。

Slack から画像を取得する

NOT A HOTEL の Slack Bot は一つのスレッドが ChatGPT でいう一つのチャット部屋に相当します。つまり conversations.replies API を使用して、スレッドのメッセージ一覧を取得しているわけです。

構成されるメッセージオブジェクトの中には files といったメッセージと共に投稿されたファイル情報も添付されます。沢山の情報が手に入りますが、今回使った項目は以下の通りになります。

  • size
  • mimetype
  • url_private_download
  • filetype
  • original_h
  • original_w
  • thumb_*

特に url_private_download は必須でしょう。この URL がファイルをダウンロードできる URL になっています。[2]

しかし普通にアクセスすると <a href="リダイレクトリンク">...</a> のような HTML のみが表示され、Slack アプリへリダイレクトされてしまいます。これが発生する理由は権限がないからです。

これを回避して、直接ダウンロードするには API リクエストに使う User OAuth Token もしくは Bot User OAuth Token を Authorization ヘッダにセットする必要があります。また、そのトークンには files:read スコープを割り当てる必要があります。

curl -H 'Authorization: Bearer xoxb-...' https://files.slack.com/.../hoge.png

Slack メッセージから GPT-4 メッセージへ変換

メッセージ変換の処理は今までと変わらず同様にできます。しかし、スレッド一覧の中から画像が添付されたメッセージを見つけた場合のみ頑張る必要があります。

  1. Slack に投稿された画像 URL を取得できるが、Vision API はこの URL へアクセスできない
  2. 画像サイズは 20MB が最大
  3. low res モードでは、512px × 512px の画像を想定。high res モードでは、画像の短辺は768px未満、長辺は2,000px未満にする必要がある[3]

2 は size を使ってフィルタリングしてあげると良いでしょう。3 も難しいことはなく TypeScript であればこのようなコードを書いて確認できそうです。(high res 前提)

function checkImageSize(file: File): boolean {
  const shortSide = Math.min(file.original_h, file.original_w);
  const longSide = Math.max(file.original_h, file.original_w);
  // 短辺が 768px 未満で、長辺が 2000px 未満かどうかを返す
  return shortSide < 768 && longSide < 2000;
}

残った 1 について考えましょう。私が思いついたアイディアです(他のアイディアも募集しています):

  • 変換のたびに画像をダウンロードする
    • R2 へアップロードして次回もその URL を指定する
    • base64 エンコードを毎回行う
  • リバースプロキシの URL を渡す

今回は後者を選択しました。メリットとして自分たちのストレージを消費することなく、レイテンシも損なわない方法だからです。[4]

さてそうなると今度は専用のリバースプロキシをどう作るかを考えます。そこで hono の mount を利用して作成しました。mount を利用すると req.url の内容は指定されているパスの部分を削除してくれます。

const allowedCidr = "40.84.182.32/28"
const allowedUA = "OpenAI Image Downloader"

hono.mount("/proxy/slack_files", async (req) => {
  if (req.method !== "GET") {
    return new Response(null, { status: 405 });
  }
  
  // 1. リクエストを許可するかどうかを判定する
  const isAllowed = (() => {
    const cfConnectingIp = req.headers.get("cf-connecting-ip");
    const userAgent = req.headers.get("user-agent") ?? "";
    const isIpAllowed =
      cfConnectingIp !== null &&
      isIpInCidr(cfConnectingIp, cidr);
    return isIpAllowed && userAgent === allowedUA;
  })();

  if (!isAllowed) {
    return new Response(null, { status: 403 });
  }

  // 2. リバースプロキシの対象となる URL を組み立てる
  // req.url === "http://localhost:8787/files.slack.com/..."
  // pathname === "/files.slack.com/..."
  const urlWithoutProto = new URL(req.url).pathname.slice(1);

  // e.g. https://files.slack.com/...
  const url = `https://${urlWithoutProto}`;
  
  // 3. リバースプロキシ
  return await fetch(url, {
    headers: {
      Authorization: `Bearer ${env.SLACK_BOT_USER_TOKEN}`,
    },
  });
});

コードのコメントに記述した通りになりますが、こんな感じの URL へアクセスすると Slack のファイルへアクセスできるようになります。

http://localhost:8787/proxy/slack_files/files.slack.com/...

色々試してみてわかったこととして、Vision API が利用するクライアントは以下の特徴を持ってそうです。

  • "OpenAI Image Downloader" という User-Agent のクライアントからリクエストする。
  • リクエスト元となる IP アドレスは 40.84.182.32/28[5] の範囲に収まってそう。
  • 更新 2024-09-04: 13.65.138.96/27, 40.84.181.32/28 も追加されたそうです。

CIDR に関しては間違えていても、社内向けツールということもあって、一旦これでよしとすることにしました。とにかく Slack のファイルへのアクセスできる範囲を絞りたかったため User-Agent と IP アドレスを確認することで、Vision API クライアントからのリクエスト以外は許可しないようにしました。

知りたい人向け isIpInCidr 関数の実装

ipaddr.js を使って実装しています。

import * as ipaddr from "ipaddr.js";

export function isIpInCidr(ip: string, cidr: string): boolean {
  try {
    const parsedIp = ipaddr.process(ip);
    const parsedRange = ipaddr.parseCIDR(cidr);
    return parsedIp.match(parsedRange);
  } catch (err) {
    return false;
  }
}

まだまだ考えることは多い

Vision API はとても素晴らしいものですが、現状ではプレビュー扱いということもあり、Rate Limit が厳しく設定されています。20 RPM (Request per minute), 100 RPD (Request per day) ということもあり簡単に制限に引っかかってしまいます。

ベストエフォートですが、これらの条件を Durable Objects を用いて観測し、制限にかかりそうであれば GPT-4 へフォールバックするといった処理を入れるようにもしていますが、これはまたどこかでお話ししたいと思います。

脚注
  1. ChatGPT を Slack で動かすのが楽しい ↩︎

  2. https://files.slack.com/... のような URL ↩︎

  3. https://platform.openai.com/docs/guides/vision/managing-images ↩︎

  4. For long running conversations, we suggest passing images via URL's instead of base64. とあるように URL を推奨しているとのことです。 ↩︎

  5. 公式ドキュメントに記載はないので確証はないです。ただ、issue として記載して欲しいとあげました。https://github.com/openai/openai-cookbook/issues/832 ↩︎

NOT A HOTEL

Discussion