「料金確認」「納期確認」「よくある質問」への返信を、自分で毎回打っていませんか。

この記事では、Cloudflare Workers を使って、LINE Bot に Claude を繋ぐ最小構成を、実際に動くレベルまで落として解説します。

1本目の記事では「何が自動化できるか」を紹介しました。今回はその次のステップとして、個人事業主が小さく公開検証しやすい構成を、コード付きでまとめます。

この記事で作るもの

ℹ️ この記事で作るBot
  • LINEで受けたテキストメッセージをWebhookで受信する
  • Cloudflare Workersで署名検証をする
  • Claude APIに質問を渡す
  • 生成した回答をLINEへ応答メッセージで返す

今回は、まず動く最小構成を優先します。

データベースや会話履歴の永続化はまだ入れません。問い合わせBotの最初の一歩として、FAQ型の一次対応Botを作るイメージです。

なぜCloudflare Workersを使うのか

LINE BotのWebhookは「HTTPを受けて、外部APIを叩いて、応答を返す」という軽い処理が中心です。Cloudflare Workersはこの用途と相性がよく、公開検証を始めやすいです。

特に無料枠で小さく試しやすいのが大きな利点です。

項目 Cloudflare Workers Free この記事での扱い
リクエスト数 1日100,000まで 小規模Botの公開検証向き
CPU時間 1リクエスト10ms 重い処理を詰め込みすぎない設計が必要
秘密情報 Secretsで管理可能 Claude APIキーやLINEトークンを安全に持てる

事前に準備するもの

  • Cloudflareアカウント
  • Node.js が使えるPC
  • LINE Developers アカウント
  • Claude APIキー
⚠️ 先に知っておきたいこと

LINE公式アカウントは無料メッセージ枠がある一方、通数が増えるとプラン確認が必要です。テスト段階では十分でも、本番では先に確認しておく方が安全です。

STEP1:LINE Developersでチャネルを作る

STEP 1

まず、LINE DevelopersでMessaging APIチャネルを作成します。

  1. プロバイダーを作成する
  2. Messaging APIチャネルを作成する
  3. Channel secret を控える
  4. Channel access token を発行して控える
  5. Webhook を有効にする準備をしておく

ここで控えるのは次の2つです。

  • LINE_CHANNEL_SECRET
  • LINE_CHANNEL_ACCESS_TOKEN
✅ 実務ポイント

自動応答Botを作るときは、LINE公式アカウント側の既存の自動応答設定とぶつからないように整理しておくと、挙動が分かりやすいです。

STEP2:Cloudflare Workersプロジェクトを作る

Cloudflare公式のC3でWorkerプロジェクトを作ります。

ターミナル
npm create cloudflare@latest -- linebot-claude
cd linebot-claude

セットアップでは、次の選択が分かりやすいです。

  • Hello World example
  • Worker only
  • JavaScript
  • Deploy はいったん No

ローカル確認はこれです。

ローカル起動
npx wrangler dev

STEP3:Secretsを設定する

Cloudflare Workersでは、秘密情報は vars ではなく Secrets を使います。

Secrets設定
npx wrangler secret put LINE_CHANNEL_SECRET
npx wrangler secret put LINE_CHANNEL_ACCESS_TOKEN
npx wrangler secret put ANTHROPIC_API_KEY

ローカルで試すときは、プロジェクト直下に .dev.vars を置いても動かせます。

.dev.vars
LINE_CHANNEL_SECRET="ここにChannel Secret"
LINE_CHANNEL_ACCESS_TOKEN="ここにChannel Access Token"
ANTHROPIC_API_KEY="ここにClaude APIキー"
⚠️ 注意

wrangler.jsoncvars に APIキーを直書きしないでください。秘密情報は Secrets か .dev.vars で扱う方が安全です。

STEP4:Workerのコードを書く

src/index.js を、以下のコードに置き換えてください。

src/index.js
// =============================================
// アプリケーション全体の設定値(定数)
// =============================================
const APP_CONFIG = {
  anthropicApiUrl: "https://api.anthropic.com/v1/messages",
  lineReplyApiUrl: "https://api.line.me/v2/bot/message/reply",
  claudeModel: "claude-sonnet-4-6",
  maxClaudeTokens: 300,
  maxReplyLength: 1000,
};

// =============================================
// ServicePolicy:サービス情報・プロンプト管理クラス
// ここを書き換えることで、業種ごとに対応を変えられる
// =============================================
class ServicePolicy {
  static buildSystemPrompt() {
    return `
あなたは個人事業主向けのLINE問い合わせBotです。
以下のルールで回答してください。

- 日本語で、丁寧なです・ます調で返答する
- わからないことは断定しない
- 料金・納期・FAQなどの定型質問に強く答える
- 個別見積もり・クレーム・判断が難しい相談は、人間対応に案内する
- 回答はできるだけ短く、要点を絞る

サービス情報の例:
- 料金: 初回相談 5,000円、通常プラン 30,000円〜
- 納期: 通常 5〜7営業日、混雑時は 10営業日前後
- 対応内容: 企画相談、文章作成支援、LINE運用相談
- 対応不可: 法務判断、税務判断、緊急クレーム処理
`;
  }
}

// =============================================
// LineSignatureVerifier:LINE Webhookの署名検証クラス
// リクエストが本当にLINEから来たものか確認する
// =============================================
class LineSignatureVerifier {
  constructor(channelSecret) {
    this.channelSecret = channelSecret;
  }

  /**
   * HMAC-SHA256で署名を生成し、LINEから届いた署名と照合する
   * @param {string} rawBody - リクエストの生のボディ文字列
   * @param {string} signature - x-line-signatureヘッダーの値
   * @returns {Promise<boolean>} 署名が一致すれば true
   */
  async verify(rawBody, signature) {
    const encoder = new TextEncoder();

    const key = await crypto.subtle.importKey(
      "raw",
      encoder.encode(this.channelSecret),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["sign"]
    );

    const signedBuffer = await crypto.subtle.sign(
      "HMAC",
      key,
      encoder.encode(rawBody)
    );

    const generatedSignature = this.toBase64(signedBuffer);
    return generatedSignature === signature;
  }

  /** ArrayBufferをBase64文字列に変換する */
  toBase64(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = "";

    for (let index = 0; index < bytes.byteLength; index += 1) {
      binary += String.fromCharCode(bytes[index]);
    }

    return btoa(binary);
  }
}

// =============================================
// ClaudeClient:Claude APIとの通信クラス
// ユーザーメッセージを受け取り、返答テキストを返す
// =============================================
class ClaudeClient {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }

  /**
   * ユーザーメッセージをClaudeに送り、返答テキストを取得する
   * @param {string} userMessage - LINEで受信したテキスト
   * @returns {Promise<string>} Claudeの返答テキスト
   */
  async generateReply(userMessage) {
    const requestBody = {
      model: APP_CONFIG.claudeModel,
      max_tokens: APP_CONFIG.maxClaudeTokens,
      system: ServicePolicy.buildSystemPrompt(),
      messages: [
        {
          role: "user",
          content: userMessage,
        },
      ],
    };

    const response = await fetch(APP_CONFIG.anthropicApiUrl, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-api-key": this.apiKey,
        "anthropic-version": "2023-06-01",
      },
      body: JSON.stringify(requestBody),
    });

    // API呼び出しが失敗した場合はフォールバックメッセージを返す
    if (!response.ok) {
      return "現在混み合っているため、後ほど担当者よりご案内します。";
    }

    const data = await response.json();
    const text = data?.content?.[0]?.text?.trim();

    if (!text) {
      return "内容を確認のうえ、後ほど担当者よりご案内します。";
    }

    // LINEの文字数制限に合わせてトリミング
    return text.slice(0, APP_CONFIG.maxReplyLength);
  }
}

// =============================================
// LineBotClient:LINE Messaging APIとの通信クラス
// replyTokenを使ってメッセージを返信する
// =============================================
class LineBotClient {
  constructor(channelAccessToken) {
    this.channelAccessToken = channelAccessToken;
  }

  /**
   * LINEの応答メッセージAPIを使ってテキストを送信する
   * @param {string} replyToken - イベントから取得したreplyToken
   * @param {string} text - 送信するテキスト
   */
  async reply(replyToken, text) {
    await fetch(APP_CONFIG.lineReplyApiUrl, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        Authorization: `Bearer ${this.channelAccessToken}`,
      },
      body: JSON.stringify({
        replyToken,
        messages: [
          {
            type: "text",
            text,
          },
        ],
      }),
    });
  }
}

// =============================================
// WebhookEventReader:LINEイベント解析クラス
// テキストメッセージのイベントだけを抽出する
// =============================================
class WebhookEventReader {
  /**
   * Webhookボディからテキストメッセージイベントを抽出する
   * @param {object} parsedBody - JSON.parseで解析済みのボディ
   * @returns {Array} テキストメッセージイベントの配列
   */
  static getTextMessageEvents(parsedBody) {
    const events = parsedBody?.events ?? [];

    return events.filter((event) => {
      return (
        event.type === "message" &&
        event.message?.type === "text" &&
        typeof event.message?.text === "string" &&
        typeof event.replyToken === "string"
      );
    });
  }
}

// =============================================
// LineBotApplication:アプリケーション本体クラス
// 各クライアントを組み合わせてWebhookを処理する
// =============================================
class LineBotApplication {
  /**
   * 環境変数からクライアントを初期化する
   * @param {object} env - Cloudflare WorkersのEnvオブジェクト
   */
  constructor(env) {
    this.signatureVerifier = new LineSignatureVerifier(env.LINE_CHANNEL_SECRET);
    this.claudeClient = new ClaudeClient(env.ANTHROPIC_API_KEY);
    this.lineBotClient = new LineBotClient(env.LINE_CHANNEL_ACCESS_TOKEN);
  }

  /**
   * HTTPリクエストを振り分けるルーターメソッド
   * @param {Request} request - Fetch APIのRequestオブジェクト
   */
  async handle(request) {
    if (request.method === "GET") {
      return new Response("ok", { status: 200 });
    }

    if (request.method !== "POST") {
      return new Response("Method Not Allowed", { status: 405 });
    }

    return this.handleWebhook(request);
  }

  /**
   * LINEからのWebhookリクエストを処理する
   * 署名検証 → テキストイベント取得 → Claude呼び出し → LINE返信
   * @param {Request} request - Fetch APIのRequestオブジェクト
   */
  async handleWebhook(request) {
    // 署名ヘッダーの存在確認
    const signature = request.headers.get("x-line-signature");

    if (!signature) {
      return new Response("Missing signature", { status: 400 });
    }

    // 署名検証はJSON.parseの前に必ず生のボディ文字列で行う
    const rawBody = await request.text();

    const isValid = await this.signatureVerifier.verify(rawBody, signature);

    if (!isValid) {
      return new Response("Invalid signature", { status: 401 });
    }

    // 検証完了後にパースしてテキストイベントを処理
    const parsedBody = JSON.parse(rawBody);
    const textEvents = WebhookEventReader.getTextMessageEvents(parsedBody);

    for (const event of textEvents) {
      const replyText = await this.claudeClient.generateReply(event.message.text);
      await this.lineBotClient.reply(event.replyToken, replyText);
    }

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

// =============================================
// Cloudflare Workers エントリーポイント
// fetchイベントをLineBotApplicationに委譲する
// =============================================
export default {
  async fetch(request, env) {
    const application = new LineBotApplication(env);
    return application.handle(request);
  },
};
ℹ️ このコードでやっていること
  • LINEのWebhookを受ける
  • 署名を検証してLINEからの正規リクエストか確認する
  • テキストメッセージだけ処理する
  • Claudeに渡して回答を作る
  • LINEの応答メッセージで返す

業種別プロンプト例

プロンプトは、ServicePolicy の中身を書き換えるだけで流用できます。以下は、そのまま叩き台として使いやすい例です。

フリーランス・コンサル向け

COPY & USE フリーランス・コンサル向け
【サービス情報】
サービス名:○○コンサルティング
提供サービス:マーケティング戦略、SNS運用支援、売上改善コンサル
料金:スポット相談(90分)22,000円/月次サポート55,000円〜
納期:初回ヒアリング後3営業日以内に提案
対応エリア:オンライン対応のため全国OK

【よくある質問】
Q. 業種・規模の制限は? → 個人〜中小企業まで対応
Q. 最低契約期間は? → 月次サポートは最低3ヶ月から
Q. 支払い方法は? → 銀行振込・クレカ払い

【人間対応へ】
個別見積もり・既存クライアントのサポート・契約解約の相談

EC事業者・ネットショップ向け

COPY & USE EC事業者向け
【サービス情報】
ショップ名:○○ショップ
配送:ご注文から3〜5営業日発送、送料全国一律600円(5,000円以上無料)
返品:到着後7日以内(オーダーメイド品除く)
ラッピング:有料300円で対応

【よくある質問】
Q. 領収書は出せますか? → ご注文時の備考欄にご記入ください
Q. 在庫がない場合は? → お取り寄せ・受注制作が可能な場合あり

【人間対応へ】
クレーム・破損・誤配送・カスタムオーダーの詳細

士業・専門家向け

COPY & USE 士業向け
【サービス情報】
事務所名:○○税理士事務所
対応業務:確定申告、法人税申告、記帳代行、税務相談
料金:個人確定申告33,000円〜、法人顧問月22,000円〜、初回相談30分無料
対応エリア:オンライン対応のため全国

【必ず人間対応にすること】
具体的な税務・法務判断(必ず専門家が対応)
税額試算・節税アドバイス・個別見積もり

STEP5:デプロイしてWebhook URLを設定する

コードを書いたら、デプロイします。

デプロイ
npx wrangler deploy

デプロイ後、CloudflareからWorkerのURLが表示されます。たとえば次のようなURLです。

Worker URL 例
https://linebot-claude.your-subdomain.workers.dev

このURLを、LINE Developers の Webhook URL に設定します。その後、Webhook を有効にし、Verify で疎通確認をします。

✅ ここが大事

署名検証は、JSONをパースする前の「生の本文文字列」で行ってください。ここを間違えると、実装自体は合っていても通りません。

STEP6:テストする

ここまでできたら、実際にLINEでBotへメッセージを送って確認します。

  • 「料金はいくらですか?」
  • 「納期はどのくらいですか?」
  • 「どんな内容に対応していますか?」

このあたりの定型質問に自然に返ってくれば、最小構成としては成功です。

つまずきやすいポイント

1. 署名検証で落ちる

一番多いのはここです。LINEのWebhook本文を、署名検証前に JSON.parse してしまうと失敗しやすいです。必ず request.text() で先に生本文を取り、検証してからパースしてください。

2. Channel Access Token と Channel Secret を取り違える

かなり起きやすいです。返信で使うのは Access Token、署名検証で使うのは Channel Secret です。

3. Claudeの返答が長すぎる

問い合わせBotでは長文すぎると逆に読まれません。max_tokens を絞り、システムプロンプトでも「短く丁寧に答える」と指定した方が安定します。

4. FAQの内容が弱い

Botの品質は、モデルよりも事前に渡すサービス情報に左右されます。料金、納期、対応内容、対応不可を、まず箇条書きで整理してください。

⚠️ 本番での注意

この構成は「まず公開して小さく回す」には向いていますが、問い合わせ件数が増えるなら、ログ保存、レート制限対策、会話履歴、FAQの外部管理を入れた方が安定します。

まとめ

Cloudflare Workers を使えば、LINE Bot と Claude API をつないだ一次対応Botを、かなり軽い構成で作れます。

今回のポイントは3つです。

  • Webhook署名検証を必ず入れる
  • Secretsで鍵を管理する
  • 全部自動化しようとせず、定型問い合わせから始める

まずは「料金」「納期」「FAQ」の3種類だけでも自動化すると、問い合わせ対応はかなり楽になります。