LINE自動返信の作り方 業種別の文例とWebhookの線引き
LINEの
この
この
この
後半のwrangler devと
LINEの自動返信は、4つの部品に分けて設計する
LINEの
| 部品 | 動くとき | 役目 | 管理画面での |
|---|---|---|---|
| あいさつ | 友だち追加の |
次に |
あいさつ |
| キーワード応答 | 決まった |
よく |
応答メッセージ |
| 営業時間外の |
人が |
受け付けた |
応答時間、 |
| 人へ |
自動で |
自動の |
手動チャット |
作る
管理画面の応答メッセージで足りる形と、Webhookが要る形の線
線を
| やりたい |
管理画面の |
Webhookで |
|---|---|---|
| 「体験」と |
できる | できる |
| 営業時間外に |
できる | できる |
| 「体験の |
完全一致なので |
できる |
| 「予約」と |
文の |
できる |
| 予約の |
設定の |
できる |
| 人に |
一時的な |
できる |
| 自社の |
設定の |
できる |
表の
実際に
| 業種 | まず |
Webhookが |
|---|---|---|
| パーソナルジム | 体験、 |
体の |
| 整体院 | 予約ページ、 |
症状の |
| 結婚式場 | フェアの |
予約した |
| リフォーム会社 | 見積もりの |
写真が |
もうreply)は、
自動返信は、あいさつから人への引き継ぎまで4つの順に作る
4つの
手順1. あいさつは、送ってほしい言葉を見せる
あいさつ
ブロック解除の
手順2. キーワード応答は、ボタンと組み合わせる
キーワード応答は、
1つの
応答メッセージを
手順3. 営業時間外の返信は、受け付けたことと返事の時期を書く
営業時間外の
文面に
手順4. 人へ引き継ぐ線は、言葉と理由で先に書く
人に
| 渡す理由 | 言葉の例 | ジム・整体院 | 式場 | リフォーム |
|---|---|---|---|---|
| 体や |
痛、 |
渡す | アレルギー、 |
― |
| 効果を |
治、 |
渡す。 |
― | ― |
| 契約・お金 | 退会、 |
渡す | 渡す | 渡す |
| 変更・取り消し | キャンセル、 |
渡す | 渡す | 渡す |
| 急ぎ | 雨漏り、 |
― | 電話を |
電話を |
| 人を求めた | 担当、電話 | 渡す | 渡す | 渡す |
管理画面だけで
渡す
そのまま使える、業種ごとの返信の文例
ここからの
業種1. パーソナルジム
| 場面 | 届く言葉 | 返す文 |
|---|---|---|
| あいさつ | 友だち追加 | 友だち追加 |
| キーワード | 体験 | 体験の |
| キーワード | 料金 | 料金は |
| キーワード | 持ち物 | 持ち物と |
| 営業時間外 | すべて | 受け付けました。 |
| 人へ渡す | 膝が |
体の |
| 人へ渡す | 退会、休会 | 担当者に |
業種2. 整体院
| 場面 | 届く言葉 | 返す文 |
|---|---|---|
| あいさつ | 友だち追加 | 友だち追加 |
| キーワード | 予約 | ご予約は |
| キーワード | メニュー | メニューと |
| キーワード | 初めて | 初めての |
| 営業時間外 | すべて | 受け付けました。 |
| 人へ渡す | 腰痛は |
お体の |
| 人へ渡す | 予約を |
担当の |
整体院で
業種3. 結婚式場
アクセス、
| 場面 | 届く言葉 | 返す文 |
|---|---|---|
| あいさつ | 友だち追加 | 友だち追加 |
| キーワード | フェア | ブライダルフェアと |
| 文の |
来年の |
ご希望の |
| 営業時間外 | すべて | 受け付けました。 |
| 人へ渡す | 見積もり、 |
ウェディングの |
| 急ぎ | 道に |
ご連絡 |
挙式日の
業種4. リフォーム会社
リフォーム会社の
| 場面 | 届く言葉 | 返す文 |
|---|---|---|
| あいさつ | 友だち追加 | 友だち追加 |
| キーワード | 見積もり | お見積もりの |
| キーワード | 施工事例 | 施工の |
| 写真が |
画像 | 写真を |
| 営業時間外 | すべて | 受け付けました。 |
| 急ぎ | 雨漏り、 |
お急ぎの |
| 人へ渡す | 工事中、 |
担当者に |
写真が
返信が止まる、典型的な設定の誤り
自動返信が
| 症状 | よく |
確かめる |
|---|---|---|
| どの |
チャットが |
「設定」 |
| どの |
チャットが |
「設定」 |
| 日中だけ、 |
応答時間内と |
応答時間と |
| 作ったのに |
利用開始を |
応答メッセージの |
| ある |
スケジュールの |
応答メッセージの |
| 文に |
キーワードが |
応答タイプと |
| 違う |
同じ |
応答メッセージの |
| 友だち追加で |
あいさつ |
「設定」 |
| Webhookに |
「Webhookの |
LINE Developersコンソールの |
| 2通届く | Webhookの |
応答メッセージを |
| Webhookの |
応答トークンの |
サーバーのreply 400。Invalid reply token) |
Webhookでは
あいさつ
Webhookで作るときの、キーワード応答の部分
ここからはsrc/index.jsをprocessEventから次のautoReplyをwebhookEventIdでの
import { autoReply } from "./auto-reply.js";
async function processEvent(env, event) {
await autoReply(env, event);
}判定は、送信とデータベースから切り離す
1通の
const JST_MS = 9 * 60 * 60 * 1000;
// 全角・半角、大文字・小文字、空白、かぎかっこの違いをなくす
export const normalize = (s) => s.normalize("NFKC").toLowerCase().replace(/[\s「」『』"']/g, "");
// 営業時間の中か。hours は日本時間の分で持つ(10:00 なら 600)
export function isOpen(ms, hours) {
const d = new Date(ms + JST_MS);
if (hours.closedDays.includes(d.getUTCDay())) return false;
const min = d.getUTCHours() * 60 + d.getUTCMinutes();
return min >= hours.open && min < hours.close;
}
// 1通の文に、どう返すかを決める。ここでは送信もデータベースも触らない
export function decide(shop, text, ms) {
const t = normalize(text);
const hit = (words) => words.some((w) => t.includes(normalize(w)));
const open = isOpen(ms, shop.hours);
// 人に渡す語を、質問の語より先に見る
const staff = shop.handoff.find((r) => hit(r.words));
if (staff) {
return { kind: "handoff", rule: staff.key, reply: staff.reply ?? (open ? shop.staffOpen : shop.staffClosed) };
}
const faq = shop.faq.find((r) => hit(r.words));
if (faq) return { kind: "faq", rule: faq.key, reply: faq.reply };
return { kind: "unknown", rule: "unmatched", reply: open ? shop.unknownOpen : shop.unknownClosed };
}順番に
店ごとの
const week = { open: 10 * 60, close: 21 * 60, closedDays: [] };
export const gym = {
hours: { ...week, closedDays: [2] }, // 2 は火曜
staffOpen: "担当者にお渡ししました。このあと、このトークでお返事します。",
staffClosed: "受け付けました。営業時間外のため、次の営業日の10時以降にお返事します。",
unknownOpen: "内容を確認して、担当者からお返事します。",
unknownClosed: "受け付けました。次の営業日の10時以降に、担当者からお返事します。",
handoff: [
{ key: "health", words: ["痛", "しびれ", "治", "効果", "効き", "ケガ", "怪我", "持病", "妊娠", "通院", "薬"] },
{ key: "contract", words: ["退会", "休会", "解約", "返金", "クレーム"] },
{ key: "change", words: ["キャンセル", "変更", "遅れ"] },
{ key: "person", words: ["スタッフ", "担当", "人と話", "電話"] },
],
faq: [
{ key: "trial", words: ["体験", "予約"], reply: "体験のお申し込みはこちらです。空いている日時から選べます。\n〔体験予約のURL〕" },
{ key: "price", words: ["料金", "値段", "いくら", "月謝"], reply: "料金はこちらのページに載せています。\n〔料金ページのURL〕" },
{ key: "items", words: ["持ち物", "服装", "手ぶら", "シャワー"], reply: "持ち物と設備はこちらです。\n〔案内ページのURL〕" },
{ key: "access", words: ["場所", "行き方", "アクセス", "駐車場", "最寄り"], reply: "地図と駐車場の案内はこちらです。\n〔地図のURL〕" },
],
};人に渡したら止め、続けて届いても1回だけ返す
送信とline_eventsに
-- 人に渡した会話。いつまで自動の返事を止めるかを持つ
CREATE TABLE conversations (
user_id TEXT PRIMARY KEY,
handoff_until INTEGER NOT NULL DEFAULT 0, -- この時刻まで自動で返さない
unknown_until INTEGER NOT NULL DEFAULT 0 -- この時刻まで「確認します」を重ねて返さない
);
-- 何に返したかの記録。お客様の文は持たない
CREATE TABLE reply_log (
webhook_event_id TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- faq / handoff / unknown / silent
rule TEXT NOT NULL, -- どの決まりに当たったか
event_timestamp INTEGER NOT NULL
);import { decide } from "./decide.js";
import { gym as shop } from "./shops.js";
const HOUR = 60 * 60 * 1000;
const HANDOFF_MS = 24 * HOUR; // 人に渡したら、24時間は自動で返さない
const UNKNOWN_MS = 30 * 60 * 1000; // 分からない文の返事は30分に1回
// Webhook の記事の processEvent から呼ぶ
export async function autoReply(env, event) {
// 1対1のトークだけ返す。standby のイベントには replyToken がない
if (event.source?.type !== "user" || event.type !== "message" || !event.replyToken) return;
const userId = event.source.userId;
const ms = event.timestamp; // 受けた時刻ではなく、送られた時刻で判定する
const c = await env.DB.prepare("SELECT handoff_until FROM conversations WHERE user_id = ?1").bind(userId).first();
if ((c?.handoff_until ?? 0) > ms) return log(env, event, "silent", "handoff");
const d = choose(event.message, ms);
if (!d) return log(env, event, "silent", event.message.type);
// 分からない文と写真への返事は、続けて届いても1回にする
if (d.kind === "unknown" && !(await claimUnknown(env, userId, ms))) return log(env, event, "silent", d.rule);
if (d.kind === "handoff") await holdForStaff(env, userId, ms);
let failure = null;
try {
await reply(env, event.replyToken, d.reply);
} catch (err) {
failure = err;
if (d.kind === "unknown") await releaseUnknown(env, userId); // 返せなかったので、次の文に返せるよう戻す
}
await log(env, event, d.kind, d.rule);
if (d.kind === "handoff") await notifyStaff(env, d.rule); // 返事に失敗しても、店には必ず知らせる
if (failure) throw failure;
}
function choose(message, ms) {
if (message.type === "text") return decide(shop, message.text, ms);
if (message.type === "image" && shop.imageReply) return { kind: "unknown", rule: "image", reply: shop.imageReply };
return null; // スタンプなどには返さない
}
function holdForStaff(env, userId, ms) {
return env.DB.prepare(
`INSERT INTO conversations (user_id, handoff_until) VALUES (?1, ?2)
ON CONFLICT (user_id) DO UPDATE SET handoff_until = excluded.handoff_until`,
)
.bind(userId, ms + HANDOFF_MS)
.run();
}
// 確かめて書く、を1文で行う。同時に届いても、取れるのは1件だけ
async function claimUnknown(env, userId, ms) {
const r = await env.DB.prepare(
`INSERT INTO conversations (user_id, unknown_until) VALUES (?1, ?2)
ON CONFLICT (user_id) DO UPDATE SET unknown_until = excluded.unknown_until
WHERE conversations.unknown_until <= ?3`,
)
.bind(userId, ms + UNKNOWN_MS, ms)
.run();
return r.meta.changes === 1;
}
function releaseUnknown(env, userId) {
return env.DB.prepare("UPDATE conversations SET unknown_until = 0 WHERE user_id = ?1").bind(userId).run();
}
function log(env, event, kind, rule) {
return env.DB.prepare(
"INSERT INTO reply_log (webhook_event_id, kind, rule, event_timestamp) VALUES (?1, ?2, ?3, ?4)",
)
.bind(event.webhookEventId, kind, rule, event.timestamp)
.run();
}
async function reply(env, replyToken, text) {
const res = await fetch(`${env.LINE_API_BASE}/v2/bot/message/reply`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}` },
body: JSON.stringify({ replyToken, messages: [{ type: "text", text }] }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`reply ${res.status}`);
}
// 店の通知先には理由だけを送る。お客様の文はチャットの画面で読む
async function notifyStaff(env, reason) {
const res = await fetch(env.STAFF_NOTIFY_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: `LINEの問い合わせを人に渡しました(理由:${reason})。チャットの画面を確認してください。` }),
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) throw new Error(`notify ${res.status}`);
}要点は
- 判定の
時刻は リファレンスでは、event.timestamp。再送された Webhookでも、 再送の 時刻ではなく イベントの 発生時刻を 表すと されています - 止める
時間は どちらも24時間、 分からない 文の 返事は 30分に 1回。 この 記事で 仮に 決めた 値で、 公式の 基準ではありません。 店の 返事の 速さに 合わせて 変えます - 店の
通知には 原文は理由だけを 書く。 チャットの 画面で 読みます。 通知先の チャットツールに 体の 話が 流れないように する ためです - 応答トークンは
待たせない。 リファレンスでは、一度だけ 使え、 Webhookを 受信してから 1分以内に 使う 必要が あり、 時間 制限に 依存した 実装を しないようにとされています - 値は
コードに 書かない。 LINE_API_BASEは本番ではhttps://api.line.meにします。LINE_CHANNEL_ACCESS_TOKENとSTAFF_NOTIFY_URLは、チャネルシークレットと 同じく wrangler secret putで登録し、 gitに 入れません
試した結果
署名付きのwrangler devの
| 試したこと | 結果 |
|---|---|
| 「体験の |
体験の |
同じwebhookEventIdの再送 |
2通目を |
| 「膝が |
人に |
| 渡した |
何も |
| 渡した |
自動の |
| 23時30分に |
営業時間外の |
| 分からない |
1通だけ返した |
| 分からない |
1通だけ返した |
| 分からない |
答えを |
グループのstandbyのイベント |
どれも |
| 写真を |
1通だけ返した |
| 23時30分に |
電話番号を |
最初の
NG 返せなかった直後の文には返す replies=0
NG 店の通知先が500でも、お客様には返す replies=0 staff=11つ目は、
もうclaimUnknownに
言葉の一致で外れる文
文の
| 文 | 判定 | 理由 |
|---|---|---|
| ちゅうしゃじょう |
分からない |
ひらがなの |
| パーキングある? | 分からない |
言い |
| タイケン | 分からない |
半角カナは |
| 痛くないです。 |
人に渡す | 否定は |
| うちの |
現地確認の |
「来て」が |
| 写真を |
施工事例の |
「写真」が |
| 腰が |
体験の案内 | 「悪い」を |
1つ目から
この
店に
やってはいけないこと
自動返信で
- 一律応答で
全部を 文の受ける。 内容に 関わらず 同じ 文が 返るので、 「退会したい」にも 案内が 返ります。 一律応答に 書くのは 「受け付けました」までにします - 自動の
返事で、 店の症状や 効果、 空き日を 言い切る。 判断が 要る 内容は、 文面に 入れず人に 渡します - 料金の
金額を 改定の文面に 直接書く。 たびに 直す 場所が 増え、 古い文が 残ります。 料金は 1つの ページに 案内します - お客様の
文を 体のそのまま 店の 通知先や 記録に 流す。 話や 連絡先が 入ります。 見られる 人を 絞った 場所に 置きます - Webhookへ
切り 返事が替えたのに 応答メッセージを オンの ままに する。 重なり、 どちらの 返事か 分からなくなります - 応答トークンを
取って 一度だけでおいて 後で 使う。 時間の 制限が あります。 後から 送る 連絡は プッシュメッセージにします。 送った 相手の 人数で 通数に 数えられるので、 送る 相手と 回数を 先に 決めます
公開前に確かめること
自動返信は、
-
自動で 答えない ことを、 言葉と 理由の 表に した - 友だち追加から、
案内どおりの 言葉を 送って 答えが 返る - 応答時間内と
応答時間外の 両方で、 意図した 文が 返る - 定休日、
祝日、 夜中に 送って、 「次の 営業日」の 案内が 正しい - 応答メッセージの
「現在の ステータス」が 想定どおり - スケジュールを
付けた 応答メッセージの 期間が 切れていない - 実際に
届いた 文を 20通ほど 試し、 違う 答えが 返らない - 人に
渡す言葉で、 自動の 返事が 止まり、 スタッフが 気づける - 急ぎの
文で 電話番号が 最初に 出る - Webhookの
場合、 「検証」が 成功し、 「エラーの 統計情報」を オンに した - Webhookの
場合、 管理画面の 応答メッセージと あいさつの 扱いを 決めた - iPhoneと
Androidの 実機で、 ボタンを 押して 返事が 届く - 文面の
直す 担当を 決めた
動かして確かめた範囲
2026年11月1日に、
| 項目 | バージョン・内容 |
|---|---|
| OS | macOS 26 |
| Node.js | 24.22.1node --test、 |
| wrangler | 4.156.0wrangler dev、 |
| compatibility_date | 2026-10-01 |
| LINEの |
手元の |
確かめた
decideの判定を 4業種の 39の 文で 確かめ、 正規化、 時間外の 文面、 営業時間の 境目を 含む42項目が 通りました - 署名付きの
イベントを 手元の Workerに 送る 結合の 試験で、 ジムの 19項目 (5回)と リフォーム会社の 3項目 (2回)が 通りました。 リフォーム会社の 分は、 auto-reply.jsの読み込みを reformに変えて 流しています - 真似る
サーバーでは、 トークンの ヘッダー、 メッセージの 件数、 文字数を 確かめて 応答を 返しています
確かめていない
- 本物の
LINEとの やり取りの すべて。 チャネルの 作成、 コンソールの 「検証」、 実際の Webhookの 形、 応答の 到着、 スマホでの 見え方は 見ていません。 真似る サーバーは ドキュメントを 読んで 書いた もので、 本物と 同じ 動きとは 限りません - 管理画面の
応答メッセージ、 あいさつ メッセージ、 応答時間の 実際の 動き。 設定の 説明は マニュアルの 記述に よる もので、 手元の アカウントで 試していません - 管理画面の
応答メッセージと Webhookを 両方オンに した ときの、 実際の 返事の 順番 - 手動チャットへの
切り 替えと、 Webhookの 返事の 関係。 この コードは 手動チャットの 状態を 見ません - Cloudflareへの
デプロイ、 本番の D1。 同時に 届く 試験は 手元の D1での 結果です - 店の
通知先。 チャットツールや メールの 代わりに、 真 似る サーバーへ POSTしただけです - 試験の
コードは 長いので 載せていません。 署名付きの イベントの 作り方はWebhookの 記事 のtest.mjsと同じです
自社でやる場合と、任せる場合
管理画面で
任せる
出典(一次情報)
確認日は
- LINEヤフー for Business マニュアル
「応答メッセージ」 :キーワード応答と一律応答、 完全一致、 キーワードの 上限51個、 スケジュール、 応答設定の 組み合わせ、 「現在の ステータス」、 複数設定した ときの 優先順、 「友だちの 表示名」 - LINEヤフー for Business マニュアル
「応答設定」 :「設定」 > 「応答設定」、 あいさつ メッセージが オフの ときは 配信されない こと - LINEヤフー for Business マニュアル
「あいさつ :送信設定のメッセージを 設定する」 オプション、 「友だちの 表示名」 - LINEヤフー for Business マニュアル
「チャット」 :一時的な手動チャットへの 切り 替え (1時間、 延長で 最長7日間) - LINEヤフー for Business マニュアル
「チャットの :応答時間、各種設定」 祝日の 応答時間設定 - LINEヤフー for Business マニュアル
「AIチャットボット(β)」 :登録したQ&Aから 回答を 選ぶこと、 利用条件 - LINE Developers
「ボットを :Webhook URLの作成する」 設定、 「検証」、 「Webhookの 利用」、 あいさつ メッセージと 応答メッセージの 既定と 併用 - LINE Developers
「LINE公式アカウントで :2022年11月30日からWebhookと チャットが 併用できるようになりました」 併用できるようになった こと - LINE Developers
「Webhookの :エラーの 原因と 統計情報を 確認する」 「エラーの 統計情報」が 初期設定で 無効な こと、 集計の 期間 - LINE Developers
「Messaging APIリファレンス」 :応答トークンの制限、 modeとstandby、timestamp、メッセージの 件数と 文字数 - LINE Developers
「メッセージを :応答メッセージと送信する」 プッシュメッセージの 違い
よくある質問
LINEの自動返信は、管理画面の設定だけで作れますか?
あいさつ、
キーワード応答が反応しないのはなぜですか?
キーワード応答は、
営業時間外だけ自動で返信するには、どう設定しますか?
「設定」
応答メッセージとWebhookは、同時に使えますか?
併用は
整体院の自動返信で、症状の質問にはどう答えればよいですか?
自動で
