ทำบอทจองคิวผ่าน 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 นี้ ให้บอกลูกค้าว่าเวลานี้เพิ่งมีคนจองไป แล้วส่งเวลาที่ยังว่างให้เลือกใหม่ การเช็คในโค้ดก่อนบันทึกอย่างเดียวไม่พอ เพราะสองคำขอที่มาพร้อมกันจะผ่านการเช็คทั้งคู่
ถ้าร้านรับได้หลายคนในเวลาเดียวกัน (เช่นมีช่างหลายคน) ให้เปลี่ยนเป็นนับจำนวนการจองในช่วงนั้นเทียบกับจำนวนที่รับได้
หลังจองสำเร็จ
- ตอบลูกค้าด้วยการ์ดสรุป (Flex Message) มีวัน เวลา บริการ และวิธียกเลิก
- แจ้งเตือนเจ้าของร้าน (push message — นับโควตา ดู โควตา LINE OA)
- ลบ session
เรื่องเวลา: เก็บวันที่ในฐานข้อมูลเป็นรูปแบบมาตรฐาน และระวังเรื่อง timezone เซิร์ฟเวอร์คลาวด์ส่วนใหญ่ใช้เวลา UTC ถ้าคำนวณ "พรุ่งนี้" ด้วยเวลาเซิร์ฟเวอร์ตอนหลังเที่ยงคืนเวลาไทยก่อน 7 โมงเช้า วันจะเพี้ยนไปหนึ่งวัน ให้แปลงเป็นเวลาไทย (Asia/Bangkok) ก่อนสร้างตัวเลือกวัน
อ่านต่อ: สร้าง Rich Menu ผ่าน API