0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Slack API x Typescript x Cloudflere でURL要約bot作ってみた

0
Posted at

Cloudflare Workers + OpenAI で Slack URL 要約ボットを作った

はじめに

Slack でリンクが共有されるたびに「これどんな内容?」と確認しにいくのが面倒で、URL を貼るだけで自動で要約してスレッドに返してくれるボットを作りました。

構成はシンプルで、サーバーレスなのでほぼコストゼロで運用できています。

技術スタック

完成イメージ

チャンネルに URL を投稿すると、スレッドに以下のような要約が返ってきます。

:memo: *概要*
この記事は〇〇について解説している。

:bulb: *ポイント*
• 〇〇は□□という理由で重要
• 実装には△△が必要

:white_check_mark: *アクション*
• □□を試してみる

image.png

実装したコード


参考にした記事:


1. Cloudflare Workers プロジェクトを作る

npx wrangler init

対話で以下を選択します。

./slack-url-summary-bot
Worker Only
Typescript
cd slack-url-summary-bot
npm i slack-cloudflare-workers@latest

2. まず URL 疎通確認だけ通す

Slack の Event Subscriptions に URL を登録するには、Slack が最初に送ってくる url_verification リクエストに正しく応答する必要があります。

まずはこれだけ通るエンドポイントを作ります。

src/index.ts
export interface Env {
  SLACK_SIGNING_SECRET: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('ok', { status: 200 });
    }

    const rawBody = await request.text();
    const body = JSON.parse(rawBody);

    // Slack の URL 登録時に飛んでくる疎通確認。challenge をそのまま返せば OK
    if (body.type === 'url_verification' && body.challenge) {
      return new Response(body.challenge, {
        status: 200,
        headers: { 'Content-Type': 'text/plain' },
      });
    }

    return new Response('ok', { status: 200 });
  },
};

ローカルで起動します。

npm run dev

3. cloudflared でローカルを Slack に繋ぐ

Slack は HTTPS の公開 URL にしかリクエストを送れないため、ローカル開発では cloudflared でトンネルを張ります。

brew install cloudflare/cloudflare/cloudflared
cloudflared tunnel --url http://localhost:8787

出力された https://xxxx.trycloudflare.com の URL を Slack アプリの Event Subscriptions > Request URL に設定します。

Event Subscriptions の Request URL 設定画面

URL が承認されたら、Subscribe to bot eventsmessage.channels を追加します。

cloudflared は起動のたびに URL が変わります。再起動したら Slack 側も更新してください。


4. 署名検証を実装する

URL 登録が通ったら、次はセキュリティ対策として 署名検証 を実装します。

Slack は全リクエストに X-Slack-SignatureX-Slack-Request-Timestamp ヘッダーを付けて送ってきます。これを検証しないと、第三者がなりすましてリクエストを送れてしまいます。

検証ロジックは src/slack/verify.ts に分離しています(詳細は割愛)。ポイントは2点です。

  1. HMAC-SHA256 で署名を計算し、ヘッダーの値と一致するか確認する
  2. タイムスタンプが現在時刻から 5 分以内 か確認する(リプレイアタック防止)
src/index.ts(抜粋)
const verifyResult = await verifySlackRequest(request, rawBody, env.SLACK_SIGNING_SECRET);
if (!verifyResult.ok) {
  return new Response('invalid signature', { status: 401 });
}

ローカルで URL 登録するとき(url_verification)は署名が一致しないと 401 になって登録できません。
.dev.varsSLACK_SIGNING_SECRET が Slack アプリの Signing Secret と完全に一致しているか確認してください。
Basic Information > App Credentials > Signing Secret


5. URL 要約機能を実装する

いよいよ本題です。メッセージ内の URL を検出し、OpenAI で要約します。

5-1. URL を抽出する

src/summarizer/fetch.ts
export function extractUrls(text: string): string[] {
  const regex = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g;
  return [...new Set(text.match(regex) ?? [])];
}

5-2. ページテキストを取得する

src/summarizer/fetch.ts
export async function fetchPageText(url: string): Promise<string | null> {
  const res = await fetch(url, {
    headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Slackbot 1.0)' },
    redirect: 'follow',
  });
  if (!res.ok) return null;

  const contentType = res.headers.get('Content-Type') ?? '';
  if (!contentType.includes('text/html') && !contentType.includes('text/plain')) {
    return null;
  }

  const html = await res.text();
  // HTML タグを除去して本文テキストを取り出す
  return html
    .replace(/<script[\s\S]*?<\/script>/gi, '')
    .replace(/<style[\s\S]*?<\/style>/gi, '')
    .replace(/<[^>]+>/g, ' ')
    .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
    .replace(/\s+/g, ' ').trim()
    .slice(0, 8000) || null;
}

5-3. プロンプトを定義する

プロンプトは専用ファイルに分離することで、ロジックに触らず文言だけ変えられます。

src/summarizer/prompts.ts
export const SUMMARIZE_SYSTEM_PROMPT = `
あなたはビジネス向け Slack チームの情報キュレーターです。
共有されたウェブページを、チームメンバーが 30 秒以内に内容を把握できるよう要約します。

## 出力フォーマット(Slack mrkdwn 記法で書くこと)

:memo: *概要*
(記事の主旨を 1〜2 文で述べる)

:bulb: *ポイント*
• (重要な情報を 2〜4 点の箇条書きで)

(アクション・推奨事項が含まれる場合のみ)
:white_check_mark: *アクション*
• (具体的なアクション項目を箇条書きで)

## ルール
- 必ず日本語で出力する
- 事実のみを書き、推測や感想を加えない
- 広告・ナビゲーションの内容は無視する
- アクションセクションは推奨事項がない場合は省略する
- フォーマットの指示文は出力しない。実際の内容だけを書く
`.trim();

5-4. OpenAI で要約する

src/summarizer/openai.ts
export async function summarizeWithOpenAI(
  apiKey: string,
  url: string,
  pageText: string,
): Promise<string> {
  const res = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: SUMMARIZE_SYSTEM_PROMPT },
        { role: 'user', content: `URL: ${url}\n\n${pageText}` },
      ],
      max_tokens: 600,
    }),
  });
  const data = await res.json();
  return data.choices?.[0]?.message?.content?.trim() ?? '要約できませんでした。';
}

5-5. エントリポイントで組み立てる

Slack は 3 秒以内に HTTP 200 を返さないとリトライしてきます。URL 取得 + OpenAI 呼び出しは 3 秒を超えることがあるので、ctx.waitUntil() でバックグラウンド処理にします。

src/index.ts(抜粋)
const urls = extractUrls(text);
if (urls.length > 0) {
  // 即座に 200 を返しつつ、バックグラウンドで要約・投稿する
  ctx.waitUntil((async () => {
    for (const url of urls) {
      const summary = await summarizeUrl(env.OPENAI_API_KEY, url);
      const message = summary ?? ':warning: リンクの内容を取得できませんでした。';
      await postThreadReply(env, event.channel!, event.ts!, message);
    }
  })());
}

6. ディレクトリ構成

機能が増えてきたので、責務ごとにファイルを分割しました。

src/
├── index.ts              # エントリポイント(リクエスト受付・Slack 投稿)
├── types.ts              # 共通型定義(Env, Slack イベント型)
├── slack/
│   ├── verify.ts         # 署名検証
│   └── client.ts         # chat.postMessage
└── summarizer/
    ├── fetch.ts          # URL 取得・HTML → テキスト変換
    ├── openai.ts         # OpenAI API 呼び出し
    ├── prompts.ts        # プロンプト定義
    └── index.ts          # summarizeUrl(要約のエントリ)

分割の考え方summarizer/ は「URL を受け取って要約テキストを返す」だけに責務を限定し、Slack への投稿は index.ts が担います。こうすることで将来 Slack 以外のチャットサービスに対応するときも summarizer/ はそのまま使えます。


7. 環境変数の設定

ローカルでは .dev.vars ファイルを作成します(Git にはコミットしないこと)。

.dev.vars
SLACK_SIGNING_SECRET="your-signing-secret"
SLACK_BOT_TOKEN="xoxb-..."
OPENAI_API_KEY="sk-..."

本番(Cloudflare Workers)には wrangler secret put で登録します。

npx wrangler login

npx wrangler secret put SLACK_SIGNING_SECRET
npx wrangler secret put SLACK_BOT_TOKEN
npx wrangler secret put OPENAI_API_KEY

8. デプロイ

手動デプロイ

npm run deploy

デプロイ後に表示される https://xxxx.workers.dev を Slack の Request URL に設定します。

GitHub Actions で自動デプロイ

main ブランチに push するたびに自動デプロイされるよう設定しました。

.github/workflows/deploy.yml
name: Deploy Worker

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
      - name: Build & Deploy Worker
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          wranglerVersion: "4.81.0"

wranglerVersion を指定しないと wrangler-action@v3 が古い v3 系をインストールしてしまい、wrangler.jsonc を読み込めずエラーになります。プロジェクトで使っているバージョンと合わせてください。

GitHub リポジトリの Settings > Secrets and variables > Actions に以下を登録します。

Secret 名
CLOUDFLARE_API_TOKEN Cloudflare の API トークン(Edit Cloudflare Workers テンプレートで作成)
CLOUDFLARE_ACCOUNT_ID Cloudflare のアカウント ID

まとめ

今回はCloudflare Workers のサーバーレス環境で Slack ボットを作ってみました

Cloudflare 自体あまりちゃんと触ったことがなかったんですが、無料枠が広く、今回の用途では実質コストゼロで運用できました。
楽しかった
それじゃばいちゃ〜

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?