Open15

名刺画像を貼るとLLMでJSONにするSlack Botをつくる(Cloudflare Workers)

hosaka313hosaka313

What

名刺画像をLLMでJSONにするのはよくあるやつ。

筆者も社内で作って提供していたのだが、インターフェースが悪くて「遅い」と4回ネチネチ言われ続けてムカついたので、Slackをインターフェースにして作り直す回。

hosaka313hosaka313

Slack Bot

Cloudflare Workersで作る

  • Cloudflare Workersのデプロイが早すぎる。
    • Firebase Functions / AWS ServerlessでSlack bot開発の経験があるが、デプロイが遅いのがストレスだった。
  • https://github.com/seratch/slack-cloudflare-workers が良すぎる。
    • Edgeランタイム対応
    • 3秒以内にレスポンスを返す必要なく、Lazyで処理できる
    • 型定義助かる。

責務

  • 画像を待ち受け(複数)
  • Base64にする
  • LLMに渡す
  • JSONを受け取る

過去に書いた記事。これはServerless。
https://qiita.com/hosaka_/items/bbe777d04752cb6804c8

3秒ルールがある場合、遅い処理はバックエンドに移譲する必要があったが、Lazyで処理できるのでバックエンドはなくてもSlack Botだけで完結する。

hosaka313hosaka313

Slack Botを作る

プロジェクト作成

$mkdir slack-to-namecard
$npx wrangler init slackbot
<途中の依存パッケージの追加は"Y">

Hello Worldテンプレート/TypeScriptで作る。

依存の追加

$cd slackbot
$rm package-lock.json
$pnpm add slack-cloudflare-workers@latest

biomeの追加(Optional)

$cd pnpm add --save-dev --save-exact @biomejs/biome
$pnpm biome init
hosaka313hosaka313

src/index.ts

まず、画像アクションに対してメッセージを返すところを目標にする。

const app = new SlackApp({ env });

app.message

といった感じでイベント受け取りをする。

ファイルの扱いは下記。
https://api.slack.com/messaging/files

まずは焦らず、hello worldを返すだけのbotを作っていく。

src/index.ts
import {
	SlackAPIClient,
	SlackApp,
	SlackEdgeAppEnv,
} from "slack-cloudflare-workers";

export default {
	async fetch(
		request: Request,
		env: Env & SlackEdgeAppEnv,
		ctx: ExecutionContext,
	): Promise<Response> {
		const app = new SlackApp({ env });

		/**
		 * 画像をBase64にエンコードしてバックエンドに送る
		 */
		app.message(/hello/, async ({ context, payload }) => {
			try {
				console.log("message event start: ", payload);
				await app.client.chat.postMessage({
					channel: context.channelId,
					text: "hello!",
					thread_ts: payload.ts,
				});
			} catch (e) {
				console.error(e);
				const errorMessage = e instanceof Error ? e.message : e;
				await postErrorResponse({
					client: app.client,
					errorMessage: `エラーが発生しました。 ${errorMessage}`,
					channelId: context.channelId,
					ts: payload.ts,
				});
			}
		});

		return await app.run(request, ctx);
	},
};

async function postErrorResponse({
	client,
	errorMessage,
	channelId,
	ts,
}: {
	client: SlackAPIClient;
	errorMessage: string;
	channelId: string;
	ts: string;
}) {
	await client.chat.postMessage({
		channel: channelId,
		text: errorMessage,
		thread_ts: ts,
	});
}
hosaka313hosaka313

Slackアプリを作る

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

から新規作成

OAuth & Permissionsからpermission追加&インストール

その後、OAuth Tokensの欄からinstallし、Bot User OAuth Tokenを取得します。

シークレットの取得

SLACK_SIGNING_SECRET

Basic Informationで取得可能。

SLACK_BOT_TOKEN

前項のBot User OAuth Token

$npx wrangler secret put SLACK_SIGNING_SECRET
$npx wrangler secret put SLACK_BOT_TOKEN
$pnpm run deploy
hosaka313hosaka313

Event Subscriptionsをセット

デプロイしたWorkersのURLをセット。Verifiedにならない場合は、secretが間違っている可能性が高い。

messageイベントを受け取りたいので、下記のようにする。(今回はDMは除く)

hosaka313hosaka313

テスト

チャンネルに追加

メンションしてあげると追加できます。

helloに返答してくれればテストOK。

hosaka313hosaka313

ファイル共有イベントをlistenする

eventのsubtypeがfile_shareであるケースを扱います。

src/index.ts
		app.event("message", async ({ context, payload }) => {
			if (payload.subtype !== "file_share") {
				return;
			}
			const files = payload.files;
			if (!files) {
				return;
			}

型補完が付くのがありがたいですね。

hosaka313hosaka313

Base64で画像を取得

https://github.com/slackapi/bolt-js/issues/2069#issuecomment-1972236603

compatibility_flags = ["nodejs_compat"]が指定されているので、Bufferが使える。
https://developers.cloudflare.com/workers/runtime-apis/nodejs/buffer/

$pnpm add -D @types/node
tsconfig.json
		"types": ["@cloudflare/workers-types/2023-07-01", "node"],

実装は色々ありそうだが、以下のようにした。

const imageUrl = file.url_private_download;
const res = await fetch(imageUrl, {
    headers: {
        Authorization: `Bearer ${context.botToken}`,
    },
});
const buffer = await res.arrayBuffer();
const base64 = Buffer.from(new Uint8Array(buffer as ArrayBuffer)).toString('base64');
const base64Data = `data:${file.mimetype};base64,${base64}`;
hosaka313hosaka313

途中経過

import {
	SlackAPIClient,
	SlackApp,
	SlackEdgeAppEnv,
} from "slack-cloudflare-workers";
import { Buffer } from 'node:buffer';

export default {
	async fetch(
		request: Request,
		env: Env & SlackEdgeAppEnv,
		ctx: ExecutionContext,
	): Promise<Response> {
		const app = new SlackApp({ env });

		/**
		 * 画像をBase64にエンコードしてバックエンドに送る
		 */
		app.event("message", async ({ context, payload }) => {
			if (payload.subtype !== "file_share") {
				return;
			}
			const files = payload.files;
			if (!files) {
				return;
			}

			const validFiles = files.filter((file) => file.id && file.mimetype?.startsWith("image/"));
			if (validFiles.length === 0) {
				await app.client.chat.postMessage({
					channel: context.channelId,
					text: "画像を添付してください",
					thread_ts: payload.ts,
				})
				return;
			}

			try {
				for (const file of files) {
					const imageUrl = file.url_private_download;
					const res = await fetch(imageUrl, {
						headers: {
							Authorization: `Bearer ${context.botToken}`,
						},
					});
					if (!res.ok) {
						throw new Error(`Failed to fetch image: ${res.status} ${res.statusText}`);
					}
					const buffer = await res.arrayBuffer();
					const base64 = Buffer.from(new Uint8Array(buffer as ArrayBuffer)).toString('base64');
					const base64Data = `data:${file.mimetype};base64,${base64}`;

					await app.client.chat.postMessage({
						channel: context.channelId,
						text: "received image!",
						thread_ts: payload.ts,
					});
				}

				console.log("message event start: ", payload);
				await app.client.chat.postMessage({
					channel: context.channelId,
					text: "",
					thread_ts: payload.ts,
				});
			} catch (e) {
				console.error(e);
				const errorMessage = e instanceof Error ? e.message : e;
				await postErrorResponse({
					client: app.client,
					errorMessage: `エラーが発生しました。 ${errorMessage}`,
					channelId: context.channelId,
					ts: payload.ts,
				});
			}
		});

		return await app.run(request, ctx);
	},
};

async function postErrorResponse({
	client,
	errorMessage,
	channelId,
	ts,
}: {
	client: SlackAPIClient;
	errorMessage: string;
	channelId: string;
	ts: string;
}) {
	await client.chat.postMessage({
		channel: channelId,
		text: errorMessage,
		thread_ts: ts,
	});
}
hosaka313hosaka313

LLMを導入

Slackには3秒ルールがあります。
https://qiita.com/totoaoao/items/e87b1541f2c387878945

LLMの処理はバックエンドに移譲しても良いのですが、われわれの使っているhttps://github.com/seratch/slack-cloudflare-workersはLazy対応しているので、3秒以上の処理でもこなせます。

LLMはAzure OpenAIを使う。Azure OpenAIの設定については省略。

ライブラリ導入

$pnpm add openai

Envに追加

worker-configuration.d.ts
// Generated by Wrangler
// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen`
interface Env {
  OPENAI_API_KEY: string;
  AZURE_OPENAI_ENDPOINT: string;
  AZURE_OPENAI_API_VERSION: string;
}
$npx wrangler secrets OPENAI_API_KEY
[...]

サービス作成

まずは簡単なもの。

services
import { AzureOpenAI } from "openai";

export class OpenAIService {
  private readonly openai: AzureOpenAI;

  constructor(env: Env) {
    this.validateEnv(env);
    this.openai = new AzureOpenAI({
      apiKey: env.OPENAI_API_KEY,
      endpoint: env.AZURE_OPENAI_ENDPOINT,
      apiVersion: env.AZURE_OPENAI_API_VERSION,
    });
  }

  async generate() {
    const response = await this.openai.chat.completions.create({
      messages: [{ role: 'system', content: 'You are a helpful assistant.' }, { role: 'user', content: 'What is the meaning of life?' }],
      temperature: 0,
      model: 'gpt-4o',
    });
    return response.choices[0].message.content;
  }

  private validateEnv(env: Env) {
    const requiredEnvVars = ['OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT', 'AZURE_OPENAI_API_VERSION'] as const;
    const missingEnvVars = requiredEnvVars.filter((envVar) => !env[envVar]);
    if (missingEnvVars.length > 0) {
      throw new Error(`Missing environment variables: ${missingEnvVars.join(', ')}`);
    }
  }
}
hosaka313hosaka313

テスト

src/index.ts
const answer = await new OpenAIService(env).generate();
await app.client.chat.postMessage({
    channel: context.channelId,
    text: answer || "No answer",
    thread_ts: payload.ts,
});

Azure OpenAIが動いていることが確認できた。

hosaka313hosaka313

画像を読む

Base64を渡して簡単な分析をしてもらう。

https://platform.openai.com/docs/guides/vision?lang=curl#uploading-base64-encoded-images

services/openaiService.ts
import { AzureOpenAI } from "openai";

export class OpenAIService {
  private readonly openai: AzureOpenAI;

  constructor(env: Env) {
    this.validateEnv(env);
    this.openai = new AzureOpenAI({
      apiKey: env.OPENAI_API_KEY,
      endpoint: env.AZURE_OPENAI_ENDPOINT,
      apiVersion: env.AZURE_OPENAI_API_VERSION,
    });
  }

  async parseImage(imageBase64: string) {
    const result = await this.openai.chat.completions.create({
      model: "gpt-4o",
      messages: [
        {
          role: "system",
          content: "日本語で回答してください。",
        },
        {
          role: "user",
          content: [
            {
              "type": "text",
              "text": "画像にはなんと書いてありますか?"
            },
            {
              type: "image_url",
              image_url: {
                url: imageBase64
              }
            }
          ]
        }]
    });

    return result.choices[0].message.content || "No answer";
  }

  private validateEnv(env: Env) {
    const requiredEnvVars = ['OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT', 'AZURE_OPENAI_API_VERSION'] as const;
    const missingEnvVars = requiredEnvVars.filter((envVar) => !env[envVar]);
    if (missingEnvVars.length > 0) {
      throw new Error(`Missing environment variables: ${missingEnvVars.join(', ')}`);
    }
  }
}
src/index.ts
const buffer = await res.arrayBuffer();
const base64 = Buffer.from(new Uint8Array(buffer as ArrayBuffer)).toString('base64');
const base64Data = `data:${file.mimetype};base64,${base64}`;
const answer = await new OpenAIService(env).parseImage(base64Data);

await app.client.chat.postMessage({
    channel: context.channelId,
    text: answer,
    thread_ts: payload.ts,
});

テスト結果。

hosaka313hosaka313

JSONにしていく

JSONモードの進化系、structured-outputsを使って、所定のJSONに構造化していく。

https://platform.openai.com/docs/guides/structured-outputs

Zodが使えるが、使わない。Valibotは使えない様子。

import { AzureOpenAI } from "openai";

export class OpenAIService {
  private readonly openai: AzureOpenAI;

  constructor(env: Env) {
    this.validateEnv(env);
    this.openai = new AzureOpenAI({
      apiKey: env.OPENAI_API_KEY,
      endpoint: env.AZURE_OPENAI_ENDPOINT,
      apiVersion: env.AZURE_OPENAI_API_VERSION,
    });
  }

  async parseImage(imageBase64: string) {
    const result = await this.openai.chat.completions.create({
      model: "gpt-4o",
      messages: [
        {
          role: "system",
          content: "日本語で回答してください。",
        },
        {
          role: "user",
          content: [
            {
              "type": "text",
              "text": "画像にはなんと書いてありますか?"
            },
            {
              type: "image_url",
              image_url: {
                url: imageBase64
              }
            }
          ]
        }],
      response_format: {
        "type": "json_schema",
        "json_schema": {
          "name": "business_card",
          "description": "名刺の情報",
          "strict": true,
          "schema": {
            "type": "object",
            "properties": {
              "is_name_card": {
                type: "boolean",
                description: "与えられた画像が明らかに名刺画像でない場合にfalse。"
              },
              "name": {
                "type": ["string", "null"],
                "description": "名刺に記載された氏名(苗字と名前の間は全角スペース)"
              },
              "name_kana": {
                "type": ["string", "null"],
                "description": "氏名のフリガナ。見つからない場合は名前から推測(苗字と名前の間は全角スペース)"
              },
              "company": {
                "type": ["string", "null"],
                "description": "会社名"
              },
              "department": {
                "type": ["string", "null"],
                "description": "部署名"
              },
              "job_title": {
                "type": ["string", "null"],
                "description": "役職"
              },
              "tel": {
                "type": ["string", "null"],
                "description": "電話番号(ハイフン区切り)"
              },
              "email": {
                "type": ["string", "null"],
                "description": "メールアドレス",
              },
              "company_url": {
                "type": ["string", "null"],
                "description": "会社のWebサイトURL",
              },
              "zip_code": {
                "type": ["string", "null"],
                "description": "郵便番号(ハイフン区切り)"
              },
              "address_1": {
                "type": ["string", "null"],
                "description": "都道府県・市区町村・番地"
              },
              "address_2": {
                "type": ["string", "null"],
                "description": "建物名・階数"
              }
            },
            "required": [
              "is_name_card",
              "name",
              "name_kana",
              "company",
              "department",
              "job_title",
              "tel",
              "email",
              "company_url",
              "zip_code",
              "address_1",
              "address_2"
            ],
            "additionalProperties": false
          }
        }
      }
    });

    return result.choices[0].message.content || "No answer";
  }

  private validateEnv(env: Env) {
    const requiredEnvVars = ['OPENAI_API_KEY', 'AZURE_OPENAI_ENDPOINT', 'AZURE_OPENAI_API_VERSION'] as const;
    const missingEnvVars = requiredEnvVars.filter((envVar) => !env[envVar]);
    if (missingEnvVars.length > 0) {
      throw new Error(`Missing environment variables: ${missingEnvVars.join(', ')}`);
    }
  }
}

テスト。

複数枚の場合も試してみる。

よき。

hosaka313hosaka313

ここから

ここまで来れば、あとはユースケースに合わせて展開させるだけ。

  • Promise.allで並列処理
  • JSONを使って何かする
    • Slack bot上でやろうとする場合は、Edge Runtime対応か注意。
  • リファクタリング
    • Slack周りの型の抽出が難しい。
  • テスト
    • Vitestを使う。が、あまり事例がない。
  • 継続的デプロイ
    • Cloudflare WorkersのCD、みんなどうやっているのかしらん。
  • Structured OutputのparserにZodを使う
  • メッセージを綺麗にする