BotKit
บทความ › แชทบอท LINE / Facebook

ทำบอทจองคิวผ่าน LINE ด้วย state machine: เก็บสถานะ กันจองซ้อน และเรื่อง timezone

บอทจองคิวที่ดีต้องถามทีละขั้น: บริการอะไร → วันไหน → กี่โมง → ชื่อ → เบอร์ → ยืนยัน ปัญหาคือ LINE ส่งข้อความมาทีละข้อความ แบบไม่มีความจำ บอทต้องจำเองว่าลูกค้าคนนี้อยู่ขั้นไหน บทความนี้เล่าโครงสร้างที่ผมใช้ในบอทจองนัดของคลินิก ซึ่งเป็นแบบ state machine ง่ายๆ เก็บสถานะไว้ในฐานข้อมูล

ขั้นตอน (state) ของการจอง

idle → service → date → time → name → phone → confirm → (บันทึกการจอง) → idle

ทุกข้อความที่เข้ามา บอททำแค่ 3 อย่าง: ดูว่าลูกค้าอยู่ขั้นไหน → ตรวจคำตอบของขั้นนั้น → บันทึกแล้วถามขั้นถัดไป

เก็บสถานะไว้ที่ไหน

อย่าเก็บในตัวแปรของโปรแกรม เพราะเซิร์ฟเวอร์รีสตาร์ตแล้วลูกค้าที่จองค้างอยู่จะหลุดหมด (ดูเหตุผลเต็มใน ทำไมห้ามเก็บ state ไว้ในแรม) ผมใช้ตารางเล็กๆ ใน Supabase

create table booking_sessions (
  line_user_id   text primary key,
  step           text not null,
  service_id     int,
  service_name   text,
  selected_date  date,
  selected_time  text,
  patient_name   text,
  patient_phone  text,
  expires_at     timestamptz not null,
  updated_at     timestamptz default now()
);

ใช้ line_user_id เป็น primary key ลูกค้าหนึ่งคนมีการจองค้างได้ครั้งละหนึ่งรายการ และใช้ upsert อัปเดตได้ตรงๆ

ให้ session หมดอายุ

ลูกค้าหลายคนจองไปครึ่งทางแล้วหายไป ถ้าวันรุ่งขึ้นเขาทักมาว่า "ร้านเปิดกี่โมง" บอทไม่ควรตอบว่า "กรุณาพิมพ์เบอร์โทร" ผมให้ session หมดอายุใน 30 นาทีหลังการตอบล่าสุด ถ้าเลยเวลาก็ลบทิ้งแล้วถือว่าเริ่มใหม่

async function setSession(uid, patch) {
  await db.from('booking_sessions').upsert({
    line_user_id: uid, ...patch,
    expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
  })
}

ใช้ Quick Reply แทนให้พิมพ์เอง

ขั้นที่มีตัวเลือกชัดเจน (บริการ วัน เวลา ยืนยัน) ให้ส่งปุ่ม Quick Reply ไปด้วย ลูกค้ากดแทนพิมพ์ บอทก็ได้ข้อความที่ตรงรูปแบบเสมอ

function quickReply(text, items) {
  return { type: 'text', text,
    quickReply: { items: items.slice(0, 13).map(label => ({
      type: 'action',
      action: { type: 'message', label: label.slice(0, 20), text: label } })) } }
}

Quick Reply ใส่ปุ่มได้ไม่เกิน 13 ปุ่ม และ label ยาวไม่เกิน 20 ตัวอักษร ถ้าเกินจะส่งไม่ผ่าน โค้ดข้างบนจึงตัดไว้ก่อนเสมอ

ขั้นที่ต้องพิมพ์เอง: ตรวจก่อนรับ

  • เบอร์โทร — ตัดเว้นวรรคและขีดออกก่อน แล้วเช็คว่าเป็นตัวเลข 9–10 หลัก ถ้าไม่ผ่าน ถามซ้ำพร้อมตัวอย่าง
  • ชื่อ — กันข้อความยาวผิดปกติ หรือข้อความที่เป็นคำสั่ง เช่น ลูกค้าพิมพ์ "ยกเลิก" ตอนบอทถามชื่อ

คำสั่งที่ต้องใช้ได้ทุกขั้น

ก่อนดูว่าอยู่ขั้นไหน ให้เช็คคำสั่งพิเศษก่อนเสมอ

  • ยกเลิก → ลบ session แล้วกลับหน้าแรก
  • ดูนัดของฉัน → แสดงการจองที่กำลังจะมาถึง

ถ้าลูกค้าพิมพ์อะไรที่ไม่ตรงกับขั้นปัจจุบัน อย่าเงียบ ให้บอกว่ากำลังรออะไรอยู่ และมีปุ่มยกเลิกให้

สิ่งที่ผมเพิ่มทีหลัง: กันจองซ้อนเวลา

เวอร์ชันแรกไม่ได้ตรวจว่าเวลานั้นมีคนจองไปแล้วหรือยัง ถ้าสองคนเลือกเวลาเดียวกันพร้อมกันก็ได้คิวทั้งคู่ วิธีที่ปลอดภัยที่สุดคือให้ฐานข้อมูลกันเอง

-- ช่วงเวลาเดียวกัน บริการเดียวกัน จองได้ครั้งเดียว (ไม่นับที่ยกเลิกแล้ว)
create unique index uniq_slot on appointments (service_id, appointment_date, appointment_time)
  where status <> 'cancelled';

ตอนบันทึกถ้าชน index นี้ ให้บอกลูกค้าว่าเวลานี้เพิ่งมีคนจองไป แล้วส่งเวลาที่ยังว่างให้เลือกใหม่ การเช็คในโค้ดก่อนบันทึกอย่างเดียวไม่พอ เพราะสองคำขอที่มาพร้อมกันจะผ่านการเช็คทั้งคู่

ถ้าร้านรับได้หลายคนในเวลาเดียวกัน (เช่นมีช่างหลายคน) ให้เปลี่ยนเป็นนับจำนวนการจองในช่วงนั้นเทียบกับจำนวนที่รับได้

หลังจองสำเร็จ

  1. ตอบลูกค้าด้วยการ์ดสรุป (Flex Message) มีวัน เวลา บริการ และวิธียกเลิก
  2. แจ้งเตือนเจ้าของร้าน (push message — นับโควตา ดู โควตา LINE OA)
  3. ลบ session

เรื่องเวลา: เก็บวันที่ในฐานข้อมูลเป็นรูปแบบมาตรฐาน และระวังเรื่อง timezone เซิร์ฟเวอร์คลาวด์ส่วนใหญ่ใช้เวลา UTC ถ้าคำนวณ "พรุ่งนี้" ด้วยเวลาเซิร์ฟเวอร์ตอนหลังเที่ยงคืนเวลาไทยก่อน 7 โมงเช้า วันจะเพี้ยนไปหนึ่งวัน ให้แปลงเป็นเวลาไทย (Asia/Bangkok) ก่อนสร้างตัวเลือกวัน

อ่านต่อ: สร้าง Rich Menu ผ่าน API

เกี่ยวกับผู้เขียน — Jark นักพัฒนา LINE Bot และระบบ AI อัตโนมัติ อยู่เชียงใหม่ ทำระบบตอบแชทและจองคิวให้ร้านค้าจริง บทความในเว็บนี้มาจากงานที่ลงมือทำเอง อ่านเพิ่ม