LINE Botの作り方:オウム返しから業務の自動応答まで

公開 書いた人 大林 諒

LINE Botは、LINE公式アカウントに届いたメッセージをMessaging API経由で自分のプログラムに渡し、プログラムが返事を決めて返す仕組みです。この記事では、予約制の小さな料理教室「サンプル料理教室」の公式アカウントを例に、まず送った言葉をそのまま返すオウム返しBotを作り、そこから「よくある質問に答える」「日程と送ると今月のレッスンをカードで返す」「ボタンで選ばせる」ところまで育てます。読み終わると、コードを1つの場所に置いて、本番のアカウントで業務の自動応答を動かせる状態になります。前提は、LINE公式アカウントを開設済みで、Messaging APIの利用を有効にしてあることです。

この記事の内容
  1. 先に決めること
  2. Botを置く場所の比較
  3. 作る前の準備
  4. オウム返しBotを作る手順
  5. 業務の応答に育てる
  6. テストのやり方
  7. 応用
  8. できないことと、その代わり
  9. よく詰まるところ
  10. 次にやること

先に決めること

Botは動いてから「標準機能で済んだ」「置き場所を変えたい」と気づくことが多い領域です。作る前に次の3つを決めます。

標準の応答メッセージで足りないか

LINE Official Account Manager(管理画面)の「応答メッセージ」は、コードを書かずにキーワードへ返事をする標準機能です。キーワード応答は、設定したキーワードと完全一致したメッセージに返事をします。1つの応答メッセージに登録できるキーワードは51個までです。「駐車場」「持ち物」と送られたら決まった文を返す、というだけなら、Botを作らずにこちらで足ります。

Botを作る意味が出てくるのは次のような場合です。

  • 返事の中身が日によって変わる(今月の日程、残りの席数)
  • 「駐車場ありますか」のように、言葉の一部で拾いたい(完全一致では拾えない)
  • ボタンを押した人に、押した内容に応じて次の返事を変えたい
  • 受けた内容をスプレッドシートやkintoneに残したい、スタッフに通知したい

サンプル料理教室では、日程と席数が毎週変わるので、Botで返す側に寄せます。よくある質問は標準の応答メッセージでも作れますが、Botと標準の応答メッセージを両方動かすと同じ言葉に2回返事が出ます。どちらか一方に寄せます。

Botをどこに置くか

Botのプログラムは、LINEから届くWebhook(HTTPSのPOSTリクエスト)を受けられる場所に置きます。置き場所で、費用、署名検証ができるか、あとから何とつなげやすいかが変わります。次の節の表で比べて、この記事では1つに絞ります。

本番とテストを分けるか

Webhook URLはチャネルに1つです。本番のチャネルのWebhook URLを試作のコードに向けると、その間は友だち全員のメッセージが試作のコードに届きます。友だちが数人のうちは本番で試しても困りませんが、友だちが増えてからは、テスト用の公式アカウントとチャネルを別に作っておきます。作り方は「テストのやり方」の節に書きます。

Botを置く場所の比較

料金・上限は2026年10月時点の各社の公式ページの値です。

置き場所費用の目安署名検証向いている会社気をつけること
Google Apps Script(GAS)無料(Googleアカウント)できない返事の材料がスプレッドシートにまとまっているdoPostの引数にリクエストヘッダーが入らない。URL Fetchは無料アカウントで1日20,000回、Google Workspaceで100,000回。1回の実行は6分まで
Cloudflare WorkersFreeは0円で1日100,000リクエスト。Paidは月5ドルからできるコードを少し書ける人が1人いる。サーバーを持ちたくないFreeはCPU時間が1リクエスト10ミリ秒
n8nn8n CloudのStarterが年払いで月20ユーロ(月2,500実行)。セルフホストはサーバー代できる(ヘッダーと生のボディを使う)予約・通知・記録を他のサービスとつなぎたいLINE用のLineノードはLINE Notify用で非推奨。Messaging APIはHTTP Requestノードで呼ぶ
Node.jsのサーバーサーバー代できる(公式SDKあり)社内にサーバーと保守できる人がいる公式SDKの @line/bot-sdk はNode.js 22以上。HTTPSの証明書を自分で用意する
Pythonのサーバーサーバー代できる(公式SDKあり)Pythonで社内ツールを作っている公式SDKの line-bot-sdk はPython 3.10以上。HTTPSの証明書を自分で用意する

Webhook URLはHTTPSで、広く信頼されている認証局の証明書が必要です。自己署名証明書は使えません。GAS・Cloudflare Workers・n8n Cloudは最初からHTTPSのURLが付くので、この点で迷いません。

署名検証は、届いたリクエストが本当にLINEプラットフォームから来たものかを確かめる手順です。リクエストヘッダーの x-line-signature と、リクエストボディをチャネルシークレットでHMAC-SHA256にかけた値を比べます。GASのdoPostが受け取る引数には、クエリ・パラメータ・パス・ボディは入りますが、ヘッダーの項目がありません。署名を確かめられないので、URLを知った第三者が偽のイベントを送れます。返事をするだけのBotなら実害は小さいものの、届いた内容をスプレッドシートに書き込むBotでは、偽のデータが混ざる入口になります。

この記事はCloudflare Workersで作る

この記事ではCloudflare Workersに置きます。理由は3つです。Freeプランの1日100,000リクエストは、小さな教室の公式アカウントでは使い切れない量です。ブラウザの管理画面だけでコードを貼って公開でき、サーバーや証明書を持たなくて済みます。署名検証が標準の機能(Web Crypto)で書けます。スプレッドシートの内容は、公開したCSVを読みに行く形で使います。

予約の確定や通知、記録までBotでやりたくなったら、その部分だけn8nに渡すのが楽です。「応用」の節で触れます。

作る前の準備

LINE側で3つの値と2つの設定をそろえます。Messaging APIの利用開始は、管理画面の「設定」>「Messaging API」>「Messaging APIを利用する」から行います。これはWeb版の管理画面からのみで、管理者の権限が要ります。選んだプロバイダーは後から変えられません。LINE DevelopersコンソールからMessaging APIのチャネルを直接作る方法は廃止されているので、必ず公式アカウント側から有効にします。

  1. LINE Developersコンソールにログインし、プロバイダーを開いて公式アカウントのチャネルを選ぶ
  2. 「チャネル基本設定」タブで「チャネルシークレット」を控える
  3. 「Messaging API設定」タブで長期のチャネルアクセストークンを発行して控える
  4. 管理画面の「設定」>「応答設定」で、「応答メッセージ」と「あいさつメッセージ」をオフにする
LINE Developersコンソールの「Messaging API設定」タブ(チャネルアクセストークンとWebhook設定の位置)
LINE Developersコンソールの「Messaging API設定」タブ(チャネルアクセストークンとWebhook設定の位置)

チャネルアクセストークンには4種類あります。長期のチャネルアクセストークンは有効期限がなく、チャネルに1件だけ発行できます。小さなBotはこれで足ります。チャネルシークレットとアクセストークンは、どちらも外に出してはいけない値です。コードに直接書かず、次の節でWorkerのシークレットに入れます。

4の「応答メッセージ」と「あいさつメッセージ」は、チャネルを作った時点で有効になっています。LINE Developersのガイドは、Messaging APIで応答するときは自動応答と混同しないようオフにすることを勧めています。

オウム返しBotを作る手順

Workerを作る

  1. Cloudflareのダッシュボードを開き、「Workers & Pages」を選ぶ
  2. 「Create application」を選ぶ
  3. 「Start with Hello World!」の「Get started」を選ぶ
  4. 「Deploy Hello World」の画面の「Worker name」に sample-cooking-bot と入れる(最初は twilight-heart-3666 のような名前が自動で入っているので、消して入れ直す)
  5. 「Deploy」を選ぶ

公開すると https://sample-cooking-bot.(アカウントごとのサブドメイン).workers.dev のURLが付きます。これがWebhook URLになります。

Cloudflareダッシュボードの「Deploy Hello World」の画面と「Worker name」の入力欄
Cloudflareダッシュボードの「Deploy Hello World」の画面と「Worker name」の入力欄

コードを貼る

  1. 作ったWorkerを開き、「Edit code」(</> のアイコン)を選ぶ
  2. worker.js の中身を消して、次のコードを貼る
  3. 「Save」を選んで保存する
export default {
  async fetch(request, env, ctx) {
    if (request.method !== 'POST') return new Response('ok');
    const body = await request.text();
    const signature = request.headers.get('x-line-signature') || '';
    if (!(await verifySignature(body, signature, env.LINE_CHANNEL_SECRET))) {
      return new Response('invalid signature', { status: 401 });
    }
    const { events = [] } = JSON.parse(body);
    ctx.waitUntil(handleEvents(events, env));
    return new Response('ok');
  },
};

async function verifySignature(body, signature, secret) {
  try {
    const enc = new TextEncoder();
    const key = await crypto.subtle.importKey(
      'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']
    );
    const sig = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
    return await crypto.subtle.verify('HMAC', key, sig, enc.encode(body));
  } catch (e) {
    return false;
  }
}

async function handleEvents(events, env) {
  for (const ev of events) {
    if (ev.type === 'message' && ev.message.type === 'text') {
      await reply(ev.replyToken, [{ type: 'text', text: ev.message.text }], env);
    }
  }
}

async function reply(replyToken, messages, env) {
  const res = await fetch('https://api.line.me/v2/bot/message/reply', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`,
    },
    body: JSON.stringify({ replyToken, messages }),
  });
  if (!res.ok) console.log('reply failed', res.status, await res.text());
}

コードのポイントは3つです。1つ目は、署名を確かめる前にボディを JSON.parse しないことです。署名はLINEが送ったボディの文字列そのものから計算するので、パースや整形をした後の文字列では一致しません。2つ目は、ctx.waitUntil で返事の処理を後ろに回し、先に 200 を返していることです。Webhookはボットサーバーが2秒以内に応答しないとタイムアウト(request_timeout)として数えられます。waitUntil に渡した処理は、レスポンスを返した後も最大30秒まで続けられます。3つ目は、LINEが疎通確認のために events が空のリクエストを送ってくることがある点です。そのときも 200 を返す必要があり、上のコードは空の配列を回すだけで 200 を返します。

Cloudflareのコードエディタ(「Edit code」で開く画面)
Cloudflareのコードエディタ(「Edit code」で開く画面)

シークレットを入れる

  1. Workerの「Settings」を開く
  2. 「Runtime variables and secrets」で「Add」を選ぶ
  3. Typeを「Secret」にし、Variable nameに LINE_CHANNEL_SECRET、Valueにチャネルシークレットを入れる
  4. もう一度「Add」を選び、LINE_CHANNEL_ACCESS_TOKEN にチャネルアクセストークンを入れる
  5. 「Deploy」を選んで反映する

登録したSecretの値は、一覧では「Value encrypted」と出て、あとから中身は見られません。コードからは env.LINE_CHANNEL_SECRET のように読めます。値を貼るときに前後の空白や改行が入ると署名検証が全部失敗するので、コピーした範囲に気をつけます。

Workerの「Settings」>「Runtime variables and secrets」。Secretの値が「Value encrypted」と出ている一覧
Workerの「Settings」>「Runtime variables and secrets」。Secretの値が「Value encrypted」と出ている一覧

Webhook URLを登録する

  1. LINE Developersコンソールで、チャネルの「Messaging API設定」タブを開く
  2. 「Webhook URL」の「編集」を選び、WorkerのURLを入れて「更新」を選ぶ
  3. 「検証」を選び、「成功」と出ることを確かめる
  4. 「Webhookの利用」をオンにする
「Webhook URL」の「検証」で「成功」と表示された状態
「Webhook URL」の「検証」で「成功」と表示された状態

LINEで確かめる

「Messaging API設定」タブのQRコードを自分のスマホで読み込み、公式アカウントを友だち追加します。「こんにちは」と送って「こんにちは」が返ってくれば完成です。返ってこないときは、Workerのログの reply failed の行を「よく詰まるところ」の表と照らし合わせます。

応答メッセージ(Reply API)で送った返事は、料金プランのメッセージ通数に数えられません(2026年10月時点)。無料のコミュニケーションプランの月200通の枠は、Botの返事では減りません。

業務の応答に育てる

オウム返しの handleEvents を、届いた言葉やボタンに応じて返事を選ぶ形に入れ替えます。サンプル料理教室では次の4つを作ります。

届いたもの返すもの使う仕組み
「駐車場ありますか」など決まった答えの文テキスト+クイックリプライ
「日程」今月のレッスンを横に並べたカードFlex Messageのカルーセル
カードの「この回を選ぶ」ボタン選んだ回の案内と予約フォームpostbackアクション
「よくある質問」質問の候補ボタンクイックリプライ

よくある質問に答える

よくある質問は、キーワードと答えの組を配列で持ち、届いた文にキーワードが含まれていれば答えを返します。標準の応答メッセージは完全一致ですが、こちらは部分一致なので「駐車場はありますか?」「車で行っても大丈夫?」のような文でも拾えます。

const FAQ = [
  { keys: ['駐車場', '車で'], text: '専用の駐車場はありません。近くのコインパーキングをご利用ください。' },
  { keys: ['持ち物', 'エプロン'], text: '持ち物はエプロンとハンドタオルです。包丁や材料は教室で用意します。' },
  { keys: ['キャンセル'], text: 'キャンセルは前日の18時までにトークでお知らせください。' },
  { keys: ['料金', '体験料', 'いくら'], text: '体験レッスンは1回3,000円(税込)です。当日教室でお支払いください。' },
];

キーワードは短くしすぎないのがコツです。実際に運用していると、「車」のような1文字のキーワードは「車庫」「電車で行きます」まで拾ってしまい、的外れな返事が出ます。2文字以上で、答えが1つに決まる言葉にします。

「日程」と送ると今月のレッスンをカードで返す

レッスンの日程はGoogleスプレッドシートで管理し、Botはそれを読みに行きます。シートは1行目を見出しにして次の形にします。

id,日付,曜日,開始,メニュー,定員,予約数
L1103,2026/11/03,火,19:00,だしから作る和定食,8,5
L1108,2026/11/08,土,10:30,週末のパン教室,8,8
L1112,2026/11/12,水,19:00,季節の野菜で作る中華,8,2
  1. スプレッドシートの「ファイル」>「共有」>「ウェブに公開」を開く
  2. 日程のシートを選び、公開の形式を選ぶ欄でCSVを指定する
  3. 「公開」を選び、表示されたURLを控える
  4. WorkerのSettingsの「Runtime variables and secrets」で、Typeを「Secret」以外(暗号化しない通常の変数)にして LESSON_CSV_URL に3のURLを入れる
  5. 同じく BOOKING_URL に予約フォームのURL(例は https://example.com/trial-booking)を入れる

公開したシートは、アカウントの設定によってはウェブ上の誰でも見られます。このシートには日程と席数だけを置き、予約した人の名前や電話番号は別のシートに分けます。元のシートを直すと公開版も自動で更新されますが、反映には数分かかることがあります。

スプレッドシートの「ウェブに公開」でシートとCSV形式を選んだ状態
スプレッドシートの「ウェブに公開」でシートとCSV形式を選んだ状態

次のコードを、オウム返しの handleEvents と置き換えて貼ります。verifySignature と reply と FAQ はそのまま使います。

async function handleEvents(events, env) {
  for (const ev of events) {
    try {
      const messages = await route(ev, env);
      if (messages && ev.replyToken) await reply(ev.replyToken, messages, env);
    } catch (e) {
      console.log('error', e.stack);
    }
  }
}

async function route(ev, env) {
  if (ev.type === 'postback') {
    return onPostback(new URLSearchParams(ev.postback.data), env);
  }
  if (ev.type !== 'message' || ev.message.type !== 'text') return null;
  const text = ev.message.text.trim();
  if (text === '日程' || text === 'レッスン日程') return [await scheduleMessage(env)];
  if (text === 'よくある質問') return [faqMenu()];
  if (text === 'スタッフに質問') {
    return [{ type: 'text', text: '質問を1通にまとめて送ってください。スタッフが営業時間内に返信します。' }];
  }
  const faq = FAQ.find((f) => f.keys.some((k) => text.includes(k)));
  if (faq) return [withMenu({ type: 'text', text: faq.text })];
  return null;
}

function todayJst() {
  const d = new Date(Date.now() + 9 * 60 * 60 * 1000);
  const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
  const dd = String(d.getUTCDate()).padStart(2, '0');
  return `${d.getUTCFullYear()}/${mm}/${dd}`;
}

async function loadLessons(env) {
  const res = await fetch(env.LESSON_CSV_URL);
  const lines = (await res.text()).trim().split(/\r?\n/).slice(1);
  return lines.map((line) => {
    const [id, date, dow, start, menu, capacity, booked] = line.split(',');
    return { id, date, dow, start, menu, left: Number(capacity) - Number(booked) };
  });
}

async function scheduleMessage(env) {
  const today = todayJst();
  const month = today.slice(0, 7);
  const lessons = (await loadLessons(env))
    .filter((l) => l.date.startsWith(month) && l.date >= today)
    .slice(0, 12);
  if (lessons.length === 0) {
    return withMenu({ type: 'text', text: '今月これからのレッスンはありません。来月の日程は月末に公開します。' });
  }
  return withMenu({
    type: 'flex',
    altText: `${month.replace('/', '年')}月のレッスン日程`,
    contents: { type: 'carousel', contents: lessons.map(lessonBubble) },
  });
}

function lessonBubble(l) {
  const open = l.left > 0;
  return {
    type: 'bubble',
    size: 'kilo',
    body: {
      type: 'box',
      layout: 'vertical',
      spacing: 'sm',
      contents: [
        { type: 'text', text: `${l.date.slice(5)}(${l.dow})${l.start}`, weight: 'bold', size: 'md' },
        { type: 'text', text: l.menu, wrap: true, size: 'sm' },
        { type: 'text', text: open ? `残り${l.left}席` : '満席', size: 'sm', color: open ? '#1DB446' : '#999999' },
      ],
    },
    footer: {
      type: 'box',
      layout: 'vertical',
      contents: [{
        type: 'button',
        style: open ? 'primary' : 'secondary',
        height: 'sm',
        action: {
          type: 'postback',
          label: open ? 'この回を選ぶ' : 'キャンセル待ちを相談',
          data: `action=${open ? 'pick' : 'wait'}&id=${l.id}`,
          displayText: `${l.date.slice(5)} ${l.start}の回について`,
        },
      }],
    },
  };
}

日付の計算に todayJst を使っているのは、Cloudflare Workersの時計が協定世界時(UTC)で動くからです。そのまま new Date() で月を取ると、日本時間の毎月1日の0時から9時までは前の月の日程が出ます。

カルーセルに入れられるバブルは12個までで、カルーセル全体のJSONは50KBまでです。月に13回以上ある教室は、「平日夜」「土日」の2つのキーワードに分けます。altText は通知やトークリストに出る文で、1,500文字まで入ります。Flex Messageの見た目は、LINE Developersの「Flex Message Simulator」にJSONを貼ると送らずに確かめられます。

「日程」と送ったあとにレッスンのカードが横に並んだトーク画面
「日程」と送ったあとにレッスンのカードが横に並んだトーク画面

postbackで選択肢を出す

カードのボタンには postbackアクションを付けています。ボタンが押されると、data に入れた文字列(最大300文字)がポストバックイベントとしてWebhookに届きます。押した人のトークには displayText の文が本人の発言として出るので、何を押したかが後から読み返せます。data は人の目に触れないので、action=pick&id=L1103 のように、プログラムが読みやすい形で入れます。

押されたあとの処理を、次のコードで足します。

async function onPostback(params, env) {
  const lesson = (await loadLessons(env)).find((l) => l.id === params.get('id'));
  if (!lesson) {
    return [{ type: 'text', text: 'この回は見つかりませんでした。「日程」と送ると最新の日程を出します。' }];
  }
  const label = `${lesson.date.slice(5)}(${lesson.dow})${lesson.start} ${lesson.menu}`;
  if (params.get('action') === 'pick' && lesson.left > 0) {
    return [{
      type: 'text',
      text: `${label}\n残り${lesson.left}席です。下の「予約フォームを開く」から名前と人数を送ると予約が確定します。`,
      quickReply: {
        items: [
          { type: 'action', action: { type: 'uri', label: '予約フォームを開く', uri: `${env.BOOKING_URL}?lesson=${lesson.id}` } },
          { type: 'action', action: { type: 'message', label: '持ち物を知りたい', text: '持ち物' } },
          { type: 'action', action: { type: 'message', label: '他の日程を見る', text: '日程' } },
        ],
      },
    }];
  }
  return [withMenu({ type: 'text', text: `${label}は満席です。キャンセル待ちはスタッフが受けます。希望の人数を送ってください。` })];
}

押されたときにもう一度シートを読み直し、席が残っているかを確かめています。カードを出してから押されるまでの間に満席になることがあるからです。予約の確定そのものは予約フォーム側に任せ、Botは「どの回か」を渡すだけにしています。Botの中で席を減らす処理を書くと、同時に2人が押したときに定員を超える、といった在庫管理の問題を抱えることになるからです。

「この回を選ぶ」を押したあとの案内文とクイックリプライ
「この回を選ぶ」を押したあとの案内文とクイックリプライ

クイックリプライで次の一言を用意する

クイックリプライは、メッセージの下に出る丸いボタンの列です。1つのメッセージに13個まで付けられ、ラベルは20文字までです。いずれかのボタンを押すか、トークに新しいメッセージが来ると消えます。複数のメッセージを1回で返したときは、最後のメッセージに付けたものだけが表示されます。

よくある質問の答えや日程のカードのあとに、毎回同じ3つのボタンを付けるため、次の関数を足します。

function quick(label, text) {
  return { type: 'action', action: { type: 'message', label, text } };
}

function withMenu(message) {
  message.quickReply = {
    items: [quick('今月の日程', '日程'), quick('よくある質問', 'よくある質問'), quick('スタッフに質問', 'スタッフに質問')],
  };
  return message;
}

function faqMenu() {
  return {
    type: 'text',
    text: '知りたい項目を選んでください。',
    quickReply: {
      items: [quick('駐車場', '駐車場'), quick('持ち物', '持ち物'), quick('キャンセル', 'キャンセル'), quick('料金', '料金')],
    },
  };
}

メッセージアクションのボタンは、押すとラベルではなく text の文が本人の発言として送られ、それがまたWebhookに届きます。「駐車場」と送られたのと同じ扱いになるので、FAQの配列を1か所直せば、手で打った人とボタンを押した人の両方に効きます。

route は、どのキーワードにも当てはまらない文には何も返しません(null)。「明日少し遅れます」のような連絡にBotが的外れな定型文を返すと、受け取った人は誰にも届いていないと感じます。Botが答えるのは決まった言葉とボタンだけにして、それ以外はスタッフがチャットで返す、という分け方が、小さな教室では事故が少ない形です。

よくある質問の答えの下にクイックリプライが3つ並んだトーク画面
よくある質問の答えの下にクイックリプライが3つ並んだトーク画面

テストのやり方

Botのテストは、自分だけで試す段階と、本番と分けて試す段階の2つで進めます。

自分だけ友だち追加して試す

友だちがまだ自分とスタッフだけの段階なら、本番のチャネルで試して構いません。応答メッセージの返事は、送った本人のトークにしか届かないからです。試す内容は次の表のとおりです。

試すこと送るもの期待する結果
FAQの部分一致「駐車場はありますか?」駐車場の答えとクイックリプライ3つ
拾わない文「明日遅れます」返事が出ない
日程「日程」今月の残りのレッスンのカード
満席の回満席のカードのボタン満席の案内
月の変わり目月末の夜に「日程」日本時間の今月の日程
公開版の反映シートの予約数を変えて数分後に「日程」残り席数が変わる

別のチャネルでテスト用を作る

友だちが増えてからは、テスト用の公式アカウントを別に作ります。コミュニケーションプランは月額0円なので、テスト用を持っても費用はかかりません(2026年10月時点)。

  1. LINE公式アカウントを新しく作り、名前を「サンプル料理教室(テスト)」にする
  2. 管理画面の「設定」>「Messaging API」でMessaging APIを有効にする
  3. Cloudflareで別のWorker(sample-cooking-bot-test)を作り、同じコードを貼る
  4. テスト用チャネルのチャネルシークレットとアクセストークンを、テスト用Workerのシークレットに入れる
  5. テスト用チャネルのWebhook URLにテスト用WorkerのURLを登録する
  6. 直したコードはテスト用Workerで確かめてから、本番のWorkerに貼る

テスト用と本番でシークレットを取り違えると、署名検証が全部失敗します。Worker名とチャネル名に「テスト」を入れて、画面を見ただけで区別できるようにしておきます。

LINEを使わずにWebhookを送ってみる

Worker単体の動きは、手元のパソコンからcurlで確かめられます。署名は、LINE Developersの検証例と同じくopensslで作ります。

BODY='{"destination":"xxxxxxxxxx","events":[]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'YOUR_CHANNEL_SECRET' -binary | openssl base64)
curl -i -X POST 'https://sample-cooking-bot-test.YOUR_SUBDOMAIN.workers.dev' \
  -H 'Content-Type: application/json' \
  -H "x-line-signature: $SIG" \
  -d "$BODY"

200 と ok が返れば署名検証は通っています。SIG の行を消して送り、401 が返ることも確かめておきます。作った返事のJSONがLINEの形式に合っているかは、「応答メッセージのメッセージオブジェクトを検証する」エンドポイント(https://api.line.me/v2/bot/message/validate/reply)に送ると、実際に送らずに確かめられます。

エラーの記録を残す

LINE Developersコンソールの「Messaging API設定」タブで「エラーの統計情報」をオンにすると、「Webhookエラー」タブにWebhookが届かなかった理由が残ります。オフだった期間のエラーは後から見られないので、作った日にオンにします。あわせて「Webhookの再送」もオンにしておくと、ボットサーバーが 200 番台を返せなかったときにLINEが送り直します。再送されたイベントには同じ webhookEventId が付くので、記録を残す処理を足すときはこの値で重複を除きます。

LINE Developersコンソールの「エラーの統計情報」と「Webhookの再送」の切り替え
LINE Developersコンソールの「エラーの統計情報」と「Webhookの再送」の切り替え

応用

返事に時間がかかるときはローディングを出す

シートの読み込みが遅い日や、外部のサービスに問い合わせる処理を足したときは、返事の前にローディングのアニメーションを出せます。https://api.line.me/v2/bot/chat/loading/start に、相手のユーザーIDと秒数(5〜60秒の5秒刻み)を送ります。

async function showLoading(userId, env) {
  await fetch('https://api.line.me/v2/bot/chat/loading/start', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`,
    },
    body: JSON.stringify({ chatId: userId, loadingSeconds: 5 }),
  });
}

route の中で「日程」を受けたときに await showLoading(ev.source.userId, env) を先に呼びます。アニメーションは1対1のトークだけで、相手がトーク画面を開いているときにしか出ません。返事のメッセージが届くと自動で消えます。対応しているのはiOS版・Android版のLINE 13.16.0以降です。

予約の書き込みや通知はn8nに渡す

「スタッフに質問」と送られたらスタッフのチャットに通知する、予約フォームの内容をkintoneに残す、といった処理は、Workerに全部書くより、Workerからn8nのWebhookにイベントを転送して、n8n側でつなぐほうが保守が楽です。n8nを置き場所にする場合、LINEのイベントを受けるのはWebhookノードです。Webhookノードには、エディタで試すための「Test URL」と、ワークフローを公開したときに使う「Production URL」があり、LINEに登録するのはProduction URLです。n8nのLineノードはLINE Notify用で、n8n 1.64.0から非推奨になっています。Messaging APIの返事はHTTP Requestノードで https://api.line.me/v2/bot/message/reply を呼びます。手順は「LINEとn8n・Make・Zapierをつなぐ:コードを書かずに自動化する」に書いています。

自分で書かずに済ませたい場合

ステップ配信・タグ付け・キーワードの自動返信を、コードではなく管理画面で設定したい会社には、Lステップ・エルメ・プロラインフリーのような拡張ツールが合います。運用する人が画面で設定を変えられることに価値がある会社では、Botを自作するより早く安定します。月額をかけたくない場合は、オープンソースの拡張ツール「L Harness」(MITライセンス)という選択肢もあります。L HarnessはCloudflare WorkersとCloudflare D1の上で動き、キーワードの完全一致・部分一致での自動返信、ステップ配信、タグ、LIFFのフォームを持っています。ソフトウェアの利用料は0円で、Cloudflareの利用料や導入・保守の手間は別にかかります。

AIで答える場合の注意

FAQの配列で拾えない質問に、ChatGPTやClaudeのようなLLMのAPIで答えさせることもできます。仕組みは、route で当てはまらなかった文をLLMのAPIに送り、返ってきた文を応答メッセージで返すだけです。作る前に次の4点を決めておきます。

  • 間違い: LLMは教室に無い割引や、存在しない日程をもっともらしく書くことがあります。料金・日程・キャンセルの条件のように間違えると困る答えはLLMに作らせず、シートやFAQの配列から返します。LLMには「この中から選んで答える、無ければスタッフに回す」と指示し、答えの元になる文を一緒に渡します
  • 個人情報: 友だちが送った文はそのままLLMの提供会社のサーバーに渡ります。使うAPIのデータの扱い(学習に使われるか、何日残るか)を契約前に確かめ、名前・電話番号・体調のような内容はトークに書かせない設計にします
  • 費用: LLMのAPIは入力と出力のトークン量に応じた従量課金です。応答メッセージ自体は通数に数えられませんが、APIの費用は送られた文の数だけ増えます。1人あたり1日の回数の上限を決めておきます
  • 時間: 応答トークンは受け取ってから1分以内に使う必要があります。LLMの返事が遅いときに備えてローディングを出し、時間がかかりすぎたら「スタッフが確認して返信します」に切り替えます

標準機能で済ませる手もあります。チャットProオプション(月額3,000円・税別、2026年10月時点)の「AIチャットボット(β)」は、登録したQ&Aの中から答えを選んで返す機能で、文を自由に作らせるより間違いが出にくくなります。Q&Aの登録と応答テストは、認定セールスパートナーが開設・管理するアカウントを除き、チャットProを購入しなくても試せます。管理画面はWeb版からのみ使えます。LLMをn8nでつなぐ詳しい作り方は、姉妹サイトのn8nのコラムに書いています。

できないことと、その代わり

できないこと理由代わりの手
前日のリマインドをBotの応答で送る応答メッセージは友だちの操作への返事にしか使えないプッシュメッセージで送る。こちらは通数に数えられる
1分以上かかる処理の結果を応答で返す応答トークンは受け取ってから1分以内、1回だけローディングを出す。間に合わない処理はプッシュで送る
1回の応答で6通以上返す1回のリクエストのメッセージは最大5つカルーセルにまとめる。カルーセルは12バブルまで
GASで署名を確かめるdoPostの引数にヘッダーが無い書き込みをするBotはCloudflare Workersかn8nに置く
Botの中で席数を正確に減らす同時に押されたときの在庫管理が要る予約の確定は予約フォームや予約システムに任せる
PC版LINEで必ずクイックリプライを出す公式の対応環境はiOS版・Android版案内の文の中にも「日程と送ってください」と書いておく
公開したシートに予約者を書く公開版は誰でも見られる場合がある日程と席数だけを公開し、予約者は非公開のシートやkintoneに残す

よく詰まるところ

症状原因直し方
「検証」で失敗するURLの打ち間違い、Workerが公開されていないWorkerのURLをコピーし直し、ブラウザで開いて ok が出るか見る
何を送っても返事が来ない「Webhookの利用」がオフ「Messaging API設定」タブでオンにする
同じ言葉に返事が2回来る管理画面の応答メッセージも動いている「設定」>「応答設定」で応答メッセージをオフにする
ログに invalid signature が並ぶ別チャネルのシークレット、値の前後の空白、ボディをパースしてから検証シークレットを貼り直す。検証は request.text() の文字列で行う
reply failed 400 で Invalid reply token1分を過ぎた、同じ応答トークンを2回使った1つのイベントに返事は1回。重い処理は後ろに回す
reply failed 401アクセストークンの貼り間違い、再発行した古い値のまま長期のトークンはチャネルに1件。再発行したら新しい値に入れ替える
「Webhookエラー」に request_timeout が出る2秒以内に応答していないctx.waitUntil で処理を後ろに回し、先に 200 を返す
「日程」で返事が来ないカルーセルが13バブル以上、50KB超、ボタンのラベルが長すぎるslice(0, 12) で切る。Flexのボタンのラベルは40文字まで
月初だけ前の月の日程が出るWorkersはUTCで動く日付は9時間足した日本時間で計算する
シートを直したのに席数が古い公開版の反映に数分かかる数分待つ。急ぐ回は手でトークに返す
クイックリプライが出ないPC版LINEで見ている、最後以外のメッセージに付けた、後に別のメッセージが来た最後のメッセージに付ける。スマホで確かめる
ボタンを押しても反応がないpostback のイベントを処理していない、data が300文字超route の先頭で postback を拾う。data は短いIDにする
n8nでテストでは動くのに本番で動かないTest URLをLINEに登録したProduction URLを登録し、ワークフローを公開する

次にやること

Botの返事の見た目をもう一段整えるなら「リッチメッセージとカードタイプメッセージ:画像とボタンで送る」で、標準機能のカードと自作のFlex Messageの使い分けを見ます。チャネルやトークンの扱いをもう一度確かめたいときは「Messaging APIの始め方:チャネル・アクセストークン・Webhook」に戻ります。予約フォームをLINEの中で開きたくなったら「LINEミニアプリとLIFF:何が違い、自社に要るか」、通知や記録をコードなしでつなぐなら「LINEとn8n・Make・Zapierをつなぐ:コードを書かずに自動化する」に進みます。

あわせて読む