名刺画像を貼るとLLMでJSONにするSlack Botをつくる(Cloudflare Workers)
What
名刺画像をLLMでJSONにするのはよくあるやつ。
筆者も社内で作って提供していたのだが、インターフェースが悪くて「遅い」と4回ネチネチ言われ続けてムカついたので、Slackをインターフェースにして作り直す回。
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。
3秒ルールがある場合、遅い処理はバックエンドに移譲する必要があったが、Lazyで処理できるのでバックエンドはなくてもSlack Botだけで完結する。
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
src/index.ts
まず、画像アクションに対してメッセージを返すところを目標にする。
const app = new SlackApp({ env });
app.message
といった感じでイベント受け取りをする。
ファイルの扱いは下記。
まずは焦らず、hello worldを返すだけのbotを作っていく。
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,
});
}
Slackアプリを作る
から新規作成
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
Event Subscriptionsをセット
デプロイしたWorkersのURLをセット。Verifiedにならない場合は、secretが間違っている可能性が高い。
messageイベントを受け取りたいので、下記のようにする。(今回はDMは除く)
テスト
チャンネルに追加
メンションしてあげると追加できます。
helloに返答してくれればテストOK。
ファイル共有イベントをlistenする
eventのsubtypeがfile_shareであるケースを扱います。
app.event("message", async ({ context, payload }) => {
if (payload.subtype !== "file_share") {
return;
}
const files = payload.files;
if (!files) {
return;
}
型補完が付くのがありがたいですね。
Base64で画像を取得
compatibility_flags = ["nodejs_compat"]
が指定されているので、Bufferが使える。
$pnpm add -D @types/node
"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}`;
途中経過
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,
});
}
LLMを導入
Slackには3秒ルールがあります。
LLMの処理はバックエンドに移譲しても良いのですが、われわれの使っているhttps://github.com/seratch/slack-cloudflare-workers
はLazy対応しているので、3秒以上の処理でもこなせます。
LLMはAzure OpenAIを使う。Azure OpenAIの設定については省略。
ライブラリ導入
$pnpm add openai
Envに追加
// 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
[...]
サービス作成
まずは簡単なもの。
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(', ')}`);
}
}
}
テスト
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が動いていることが確認できた。
画像を読む
Base64を渡して簡単な分析をしてもらう。
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(', ')}`);
}
}
}
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,
});
テスト結果。
JSONにしていく
JSONモードの進化系、structured-outputs
を使って、所定のJSONに構造化していく。
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(', ')}`);
}
}
}
テスト。
複数枚の場合も試してみる。
よき。
ここから
ここまで来れば、あとはユースケースに合わせて展開させるだけ。
- Promise.allで並列処理
- JSONを使って何かする
- Slack bot上でやろうとする場合は、Edge Runtime対応か注意。
- リファクタリング
- Slack周りの型の抽出が難しい。
- テスト
- Vitestを使う。が、あまり事例がない。
- 継続的デプロイ
- Cloudflare WorkersのCD、みんなどうやっているのかしらん。
- Structured OutputのparserにZodを使う
- メッセージを綺麗にする