ให้บอทส่งหน้าเว็บแทนการคุยจนจบ
ใช้ LINE LIFF ในแชทบอทร้านสัตว์เลี้ยง
Petcare Assistant เป็นผู้ช่วย AI และ CRM บน LINE สำหรับร้านสัตว์เลี้ยง ทั้งคลินิก ร้านอาบน้ำตัดขน และที่รับฝากเลี้ยง ผมทำคนเดียว มีเดโมอยู่ที่ https://petcare.rattanun.dev ต่อกับ LINE OA จริง
แชทบอท AI ชวนให้คิดว่าทุกอย่างควรจบในแชท ลูกค้าพิมพ์ บอทถาม ลูกค้าตอบ จนจองเสร็จ แต่ระบบนี้เดินไปอีกทาง งานที่ต้องเลือกหลายอย่าง เช่น จองคิว ดูราคา ดูนัด และยกเลิกนัด ย้ายไปอยู่บนหน้าเว็บสามหน้าที่เปิดซ้อนบนแชท LINE (LIFF) ส่วนบอทมีหน้าที่ตอบคำถามแล้วแนบปุ่มเปิดหน้าที่ถูก กฎข้อหนึ่งใน CLAUDE.md ของ repo เขียนไว้ว่า “Prefer the rich menu and LIFF pages over routing everything through the model” บทความนี้เล่าว่ากฎนี้มาจากไหน หน้าเว็บทั้งสามทำงานอย่างไร และต้องแลกกับอะไร
LIFF คืออะไร
LIFF (LINE Front-end Framework) คือหน้าเว็บธรรมดาที่ LINE เปิดในเบราว์เซอร์ของแอปเอง ซ้อนอยู่บนห้องแชท ลิงก์มีหน้าตาแบบ https://liff.line.me/<liffId>/book สิ่งที่หน้าเว็บทั่วไปทำไม่ได้แต่ LIFF ทำได้คือขอ ID token ของ LINE Login จากตัวแอป LINE ได้ทันที ลูกค้าไม่ต้องสมัครหรือ login อะไร
ในโปรเจกต์นี้มีสามหน้า อยู่ใน apps/web/src/app/liff/[liffId]/
| หน้า | ชื่อในเมนู | ทำอะไร | ต้องรู้ว่าเป็นใครไหม |
|---|---|---|---|
/book |
จองคิว | เลือกน้อง (หรือเพิ่มน้องใหม่) บริการและสาขาที่จองออนไลน์ได้ ปฏิทินวันที่มีคิวว่าง เวลาหรือวันเช็คอิน–เช็คเอาท์ ดูราคา แล้วยืนยัน | ใช่ (ID token) |
/prices |
ราคาและโปรโมชัน | โปรโมชันที่ใช้ได้ตอนนี้ ราคาบริการแยกตามสาขาและหมวด ปุ่ม “จองบริการนี้” ที่เปิด /book?branch=&service= |
ไม่ (หน้าสาธารณะ) |
/pets |
สัตว์เลี้ยงของฉัน | น้องแต่ละตัว ข้อมูลสำคัญที่ร้านบันทึก วัคซีนและการดูแลพร้อมวันครบกำหนด นัดที่จะถึง และปุ่ม “ยกเลิกนัด” | ใช่ (ID token) |
หน้าเหล่านี้เขียนด้วย Next.js กับ Tailwind ไม่ใช่ Ant Design แบบ dashboard ของร้าน เพราะเป็นหน้าที่เจ้าของสัตว์เห็น
ทำไมต้องเป็นหน้าเว็บ: สี่เหตุผล
1. รู้ว่าลูกค้าเป็นใครโดยไม่ต้อง login
ในแอป LINE หน้า LIFF ได้ ID token มาเลย ฝั่งเซิร์ฟเวอร์ส่ง token นี้ไปให้ LINE ตรวจ แล้วจับคู่ผู้ใช้กับลูกค้าคนเดียวกับที่คุยอยู่ในแชท ไม่ต้องมีการสมัคร ไม่มีรหัสผ่าน และไม่ต้องส่งลิงก์ลับที่ใครได้ไปก็เปิดได้ ที่ทำได้เพราะ Messaging API channel (ตัวรับแชท) กับ LINE Login channel (ตัวที่ LIFF app สังกัด) อยู่ใต้ provider เดียวกันใน LINE Developers ทำให้ userId ที่หน้าเว็บได้ตรงกับ userId ที่ webhook เห็น (docs/specs/M8.md)
เรื่องนี้มีผลกับวิธีที่บอทพูดด้วย prompt บอกบอทว่าร้านไม่มีการสมัครสมาชิก ลูกค้าทุกคนที่เป็นเพื่อนกับ OA มีข้อมูลในระบบตั้งแต่เพิ่มเพื่อนหรือทักมาครั้งแรกอยู่แล้ว เวลาลูกค้าถามว่า “เป็นสมาชิกหรือยัง” หรือ “ลงทะเบียนยังไง” บอทจะตอบสั้น ๆ แล้วแนบปุ่มสัตว์เลี้ยงของฉัน
2. ให้ลูกค้าเลือกจากรายการจริง แทนที่โมเดลจะถามทีละเทิร์น
ก่อนวันที่ 2 พฤศจิกายน บอทจองในแชทเอง การจองหนึ่งครั้งต้องรู้ห้าอย่าง คือ บริการ สาขา น้อง วันที่ และเวลา ทุกอย่างผ่านการตีความของโมเดล และแต่ละขั้นก็ผิดได้

ตัวอย่างจริงบน production ลูกค้าขอฝากเลี้ยงแมวที่สาขาบางนา บอทตั้งชื่อบริการเองว่า “โรงแรมแมว” ได้ service_not_found กลับมา ขอให้ลูกค้าสะกดคำที่บอทคิดขึ้นเองใหม่ แล้วยังบอกว่าบางนาไม่มีบริการนี้ ทั้งที่บางนาเป็นสาขาเดียวที่มี (รายละเอียดอยู่ในบทความบั๊ก AI เรื่อง 7) ส่วนหน้าจองที่ทดสอบบนมือถือไปเมื่อ 28 ตุลาคมไม่มีจุดผิดแบบนี้เลย เพราะลูกค้าเลือกแต่ละอย่างจากรายการที่ระบบสร้างให้
เจ้าของโปรเจกต์ (ผมเอง) จึงตัดสินใจว่า เมื่อลูกค้าอยากจอง บอทจะแนบปุ่ม จองคิว ที่เปิดหน้าเดียวกับช่องจองคิวใน rich menu บอทยังตอบคำถามแบบ “เสาร์นี้ว่างไหม” ด้วย check_availability ได้ แต่จะไม่จอง ไม่กันคิว และไม่พูดว่าจองแล้ว tool hold_booking ถูกถอดออกจากโมเดล เพราะถ้าเก็บไว้ เส้นทางที่เคยพังก็ยังอยู่
การยกเลิกนัดเป็นบทเรียนเดียวกัน วันที่ 5 พฤศจิกายนบน production ลูกค้าขอยกเลิกนัด บอทขอ “รหัสการจอง” ที่ลูกค้าไม่เคยเห็น แล้วอ่าน B1 ซึ่งเป็นตัวอ้างอิงภายในที่ list_my_bookings ส่งให้ cancel_booking ออกมาเป็นรหัส ลูกค้าตอบ “ใช่ครับ” แต่ตอนนั้นรหัสหมดอายุไปแล้ว (รหัสอยู่ได้แค่เทิร์นเดียว เพื่อไม่ให้รหัสเก่าไปยกเลิกผิดนัด) โมเดลเลยบอกลูกค้าว่าไม่มีนัดนี้ นัดจึงยังค้างอยู่ ถ้าให้รหัสอยู่นานขึ้น ก็จะเจอปัญหาที่แย่กว่า คือรหัสชี้ไปที่นัดอื่น ทางแก้ที่เลือกคือเอา cancel_booking ออกจากบอท แล้วใส่ปุ่ม ยกเลิกนัด ใต้นัดแต่ละรายการบนหน้าสัตว์เลี้ยงของฉัน ลูกค้ากดที่นัดที่ตัวเองเห็นอยู่ ไม่ต้องจำหรือบอกรหัสอะไร การเปลี่ยนนี้ merge เข้า main แล้วใน PR #25
3. ลูกค้าไม่ต้องออกจาก LINE
หน้า LIFF เปิดซ้อนบนแชท หน้าจองและหน้าราคามีปุ่ม “กลับไปที่แชท” ที่เรียก liff.closeWindow() ปิดหน้ากลับเข้าห้องแชท พอจองเสร็จ ข้อความยืนยันก็ส่งเข้าแชทเดิม เป็นการ์ดที่มีชื่อน้อง บริการ วันเวลา สาขา และเบอร์โทร ถ้าต้องจ่ายมัดจำ การ์ดจะบอกยอดและเวลาที่ต้องโอน ลูกค้าส่งสลิปในแชทเดียวกันนั้น
4. การเปิดหน้าเว็บไม่เรียกโมเดล
ทุกเทิร์นในแชทเรียก LLM และเสียเงิน ตอนทดสอบบนเซิร์ฟเวอร์จริงครั้งแรกด้วย gemini-2.5-flash ตกเทิร์นละราว 0.0015–0.0019 ดอลลาร์ และใช้เวลา 3.4–5.0 วินาที แต่ API ของหน้า LIFF เรียกแค่ services ของระบบ ไม่มีโมเดลอยู่ในเส้นทางนี้เลย การดูราคาทั้งร้าน การกดดูปฏิทินสิบครั้ง หรือการเลือกเวลาใหม่ จึงไม่มีค่า token ช่อง “สาขาและเวลาเปิด” ใน rich menu ก็เปลี่ยนด้วยเหตุผลเดียวกัน แต่ก่อนช่องนี้ส่งข้อความเข้าโมเดล ซึ่งเสียเงินหนึ่งเทิร์น และเปิดโอกาสให้โมเดลตอบตารางเวลาของร้านผิด ตอนนี้เป็น postback ที่โค้ดตอบเองจากการตั้งค่าสาขา
ลูกค้าไปถึงหน้าเว็บได้สามทาง

rich menu มีหกช่อง แถวบนเป็นลิงก์ไปสามหน้า LIFF แถวล่างคือสาขาและเวลาเปิด โทรหาร้าน และคุยกับพนักงาน เมนูสร้างจาก richMenuDefinitions (packages/channels/src/rich-menu.ts) เมื่อร้านกด “ติดตั้งเมนู LINE” ใน dashboard ระบบมีเมนูสองชุดต่อหนึ่ง channel ตามสถานะการผูกบัญชีของลูกค้า แต่ไม่มีโค้ดที่ผูกบัญชีตัวไหนเรียก LINE เอง ใน worker มี poller ชื่อ rich-menu-sync ทำงานทุก 30 วินาที คอยเทียบเมนูที่ลูกค้าแต่ละคนควรเห็นกับเมนูที่ติดตั้งไปแล้ว ถ้าไม่ตรงก็เรียก LINE ให้เปลี่ยน (syncRichMenus ใน packages/pipeline/src/rich-menus.ts) การผูกบัญชีมีอยู่สี่ทาง และทางที่จะมีในอนาคตก็จะใช้กลไกนี้ได้ทันที
tool ของบอท มีสองตัว send_booking_link ใช้กับหน้าจอง ถ้าลูกค้าพูดถึงบริการและสาขาที่จองออนไลน์ได้ จะเติม ?branch=&service= ให้หน้าเปิดมาที่บริการนั้นเลย ส่วน send_page_link ใช้กับหน้าราคาและหน้าสัตว์เลี้ยงของฉัน โมเดลบอกได้แค่ว่าต้องการหน้าไหน URL สร้างในโค้ดจาก liff_id ของ channel ที่แชทนี้อยู่ ปุ่มที่บอทแนบจึงเปิดได้แค่หน้าของร้านนี้
async execute({ page }) {
if (!scope.liffId) return noPageFallback(page);
scope.onPageLink(page, liffPageUrl(scope.liffId, page));
return { status: "button_attached", tellCustomer: PAGE_HINTS[page] };
},
ปุ่มที่ข้อความสัญญาไว้ ในการทดสอบวันที่ 4 พฤศจิกายน โมเดลเขียนว่า “กดปุ่ม “จองคิว” ด้านล่าง” โดยไม่เรียก tool ถึง 3 ใน 5 ครั้ง ตอนนี้ engine อ่านคำตอบด้วย pagesPromisedInText (packages/core/src/page-links.ts) แล้วแนบปุ่มที่ข้อความพูดถึงให้เอง นับเฉพาะคำว่า “ปุ่ม” ที่ตามด้วยชื่อหน้าเท่านั้น
export function pagesPromisedInText(text: string): LiffPage[] {
return LIFF_PAGE_ORDER.filter((page) =>
new RegExp(`ปุ่ม\\s*["“'‘]?\\s*${LIFF_PAGE_LABELS[page]}`).test(text),
);
}
ในคอมมิตเดียวกัน prompt ถูกปรับให้ถือว่าประโยคแบบ “อยากพาแมวไปอาบน้ำต้องทำยังไง” คือความต้องการจอง ก่อนหน้านั้นลูกค้าต้องตอบคำถามเรื่องสายพันธุ์ น้ำหนัก และราคา ก่อนจะได้ปุ่ม รวมห้าข้อความ ทั้งที่หน้าจองก็ถามข้อมูลพวกนี้อยู่แล้ว ตอนนี้บอทส่งปุ่มจองคิวและปุ่มราคาเป็นอย่างแรก รันทดสอบแบบเจาะจง 5 เคส × 5 ครั้ง ผ่าน 25/25
ปุ่มเป็นการ์ด ไม่ใช่ quick reply
ตอนแรกปุ่มเปิดหน้าเป็น quick reply ซึ่งหายไปทันทีที่ลูกค้าพิมพ์ข้อความใหม่ (quick reply แสดงบน LINE desktop รุ่นปัจจุบันด้วย เจ้าของร้านเช็กเมื่อ 6 พฤศจิกายน ปัญหาจึงอยู่ที่ปุ่มหาย ไม่ใช่ว่าคอมมองไม่เห็น) ตั้งแต่ 4 พฤศจิกายน ปุ่มเหล่านี้ถูกส่งเป็นการ์ด Flex แยกอีกข้อความต่อจากคำตอบ (pageButtonsCard ใน packages/pipeline/src/cards.ts) การ์ดค้างอยู่ในแชท ลูกค้าเลื่อนกลับมากดได้ ข้อความบนการ์ดมาจากข้อความตายตัวในโค้ดหรือข้อมูลของร้าน ไม่ได้มาจากโมเดล
ความปลอดภัย: หน้าเว็บส่งมาแค่สองอย่าง
หน้า LIFF เป็นหน้าเว็บที่ใครจะเปิดก็ได้ ถ้าเซิร์ฟเวอร์เชื่อ contactId ที่หน้าส่งมา ใครก็แก้ค่าแล้วดูประวัติวัคซีนของคนอื่นได้ กฎข้อแรกของ M8 จึงเขียนไว้ว่าหน้าเว็บส่งได้แค่ LIFF ID (บอกว่าเป็นร้านไหน) กับ ID token (LINE รับรองว่าเป็นใคร) ห้ามส่ง id ของลูกค้า ร้าน หรือ channel มาเลือกขอบเขตข้อมูลเด็ดขาด

ทุกคำขอที่ POST /api/liff/* ต้องผ่านลำดับขั้นนี้ ซึ่งเขียนไว้ที่เดียวใน packages/pipeline/src/page-chain.ts
-
จำกัดอัตรา 60 ครั้งต่อนาทีต่อ IP ทุก endpoint ถ้าเกินได้ 429
{ status: "rate_limited" }ส่วนholdกับconfirmซึ่งท้ายที่สุดทำให้เกิด push message มีเพดานเพิ่มอีกชั้น คือ 10 ครั้งต่อชั่วโมงต่อผู้ใช้ LINE ที่ตรวจ token แล้ว (apps/api/src/liff/rate-limit.ts) ลูกค้าที่สลับระหว่างเน็ตมือถือกับ Wi-Fi ก็ยังนับเป็นคนเดียว และเพราะนับเฉพาะผู้ใช้ที่ตรวจแล้ว คนที่ปลอม token มาจึงใช้โควตาของลูกค้าคนอื่นไม่ได้ -
schema แบบ
.strict()ถ้า body มี key เกินมา เช่นcontactIdจะถูกปฏิเสธ ไม่ใช่ถูกตัดทิ้งเงียบ ๆconst auth = z.object({ liffId: z.string().trim().min(1).max(100), idToken: z.string().min(1).max(8192), }); export const bookBodySchemas = Object.fromEntries( BOOK_ACTIONS.map((action) => [action, auth.extend(inputs[action].shape).strict()]), ); -
หาร้านจาก LIFF ID ด้วย
findChannelByLiffIdขั้นนี้เป็นการอ่านเพียงครั้งเดียวที่ใช้systemDb()ซึ่งข้าม RLS เพราะเป็นการยืนยันตัวตน ตอนนี้ยังไม่รู้ว่าเป็นร้านไหน -
ตรวจ ID token กับ LINE โดยใช้ LINE Login channel id ของร้านนั้นเป็น
client_idถ้า LINE ปฏิเสธ จะได้invalid_tokenและหน้าเว็บจะให้ login ใหม่ ถ้า LINE ล่มหรือเน็ตมีปัญหา จะได้ 502 และหน้าเว็บมีปุ่มลองใหม่ เพื่อไม่ให้ลูกค้าถูกสั่งให้ login ใหม่ทั้งที่ไม่ได้ทำอะไรผิด token ที่ตรวจผ่านแล้วถูก cache ไว้ในหน่วยความจำจนถึงเวลาexpของมัน key ของ cache คือ SHA-256 ของ login channel id กับ token ไม่ใช่ตัว token เอง เพื่อให้ heap dump ไม่มี token ที่ LINE ยังรับอยู่หลุดออกไป หน้าจองที่เรียก API หกครั้งจึงตรวจกับ LINE แค่ครั้งเดียว -
หา channel identity จากคู่ (channel,
userId) ขั้นนี้ไม่สร้าง identity ใหม่ ถ้าไม่เจอ หรือลูกค้าบล็อก OA ไว้ จะได้not_followingพร้อมลิงก์เพิ่มเพื่อนของ OA เพราะข้อความยืนยันและการเตือนนัดต้องส่งเข้าแชท หน้าเว็บจึงแสดงปุ่ม “เพิ่มเพื่อน LINE ร้านก่อน” กับ “เพิ่มแล้ว ลองอีกครั้ง” -
ทำงานในนามบอท แต่จำกัดอยู่ที่ลูกค้าคนนี้ ใน
withTenantระบบหา contact กับแชท LINE ของลูกค้า (ถ้ายังไม่เคยทักมาก็เปิดแชทให้) แล้วสร้างBotActorที่ผูกกับ contact นั้น
const actor: BotActor = {
kind: "bot",
organizationId,
conversation: {
contactId: resolved.contactId,
branchId: resolved.conversation.branchId,
identityVerified: resolved.identity.verificationStatus === "verified",
},
};
ขั้นสุดท้ายนี้ตั้งใจไว้ ระบบยังไม่มี actor ประเภท “ลูกค้า” กฎฝั่งลูกค้าใน services ทั้งเจ็ดข้อเขียนด้วยเงื่อนไข ctx.actor.kind === "bot" เช่น ห้ามเลื่อนนัดในช่วงที่ยกเลิกฟรีไม่ได้แล้ว และต้องให้ร้านอนุมัติถ้าน้องมีคำเตือน หน้าเว็บใช้ actor ตัวเดียวกับบอท จึงทำได้ไม่เกินที่บอททำได้ (docs/specs/M9.md กฎข้อ 3) ค่า subjectId, holdId หรือ bookingId ที่หน้าส่งมาแค่ถูกนำไปตรวจ ไม่ได้ให้สิทธิ์อะไร ถ้าส่ง bookingId ของคนอื่นมา cancelBooking จะตอบ ForbiddenError ส่วน “หนึ่งแชทกันคิวได้หนึ่งที่” ก็ครอบคลุมทั้งหน้าเว็บและแชทพร้อมกัน เพราะการกันคิวจากหน้าเว็บใช้แชท LINE ของลูกค้าเป็น conversationId เพดานนัดล่วงหน้า 3 นัดต่อลูกค้าก็ใช้กับหน้าเว็บด้วย
หน้าราคาเป็นข้อยกเว้นเดียว หน้านี้ไม่ต้องใช้ token และไม่ต้องเป็นเพื่อนกับ OA ใช้ resolvePublicPage ซึ่งสร้าง PublicActor ที่มีสิทธิ์แค่ catalog.view กับ knowledge.view ของร้านนั้น และ recordAudit จะปฏิเสธ actor ประเภทนี้ ถ้ามีโค้ดเขียนข้อมูลที่ลืมตรวจสิทธิ์ transaction ก็จะ rollback
หน้าเว็บกับ API อยู่คนละ service
หน้า LIFF อยู่ใน apps/web (Next.js) แต่ API อยู่ใน apps/api (NestJS) หน้าเว็บเรียก /api/liff/… บน origin ของตัวเอง แล้ว Next ส่งต่อด้วย rewrite แบบ beforeFiles (apps/web/next.config.ts) จึงไม่ต้องตั้ง CORS และไม่มี route ใน app/ ตัวไหนมาบังเส้นทางนี้ได้ ส่วน apps/web ยังเป็นแค่ชั้นส่งต่อ ไม่ได้ตัดสินอะไรเอง
สิ่งที่ต้องแลก และข้อจำกัด
- แต่ละร้านต้องตั้ง LINE เพิ่ม นอกจาก Messaging API channel แล้ว ร้านต้องมี LINE Login channel ที่อยู่ใต้ provider เดียวกัน มี LIFF app ที่ endpoint ชี้มาที่
/liff/<liffId>และต้องกรอก LIFF ID กับ Login channel id ใน dashboard ถ้าไม่มีสิ่งเหล่านี้findChannelByLiffIdจะคืน null และ tool ของบอทจะตอบno_pageแล้วบอกให้ลูกค้ากดเมนูแทน - ข้อความยืนยันต้องใช้ push หน้า LIFF ไม่มี reply token แบบ webhook ดังนั้นการจองจากหน้าเว็บ (
confirmFromPage→confirmHold(…, { notifyCustomer: true })) จึงส่งข้อความยืนยันเข้าแชทเป็น push หนึ่งข้อความต่อการจอง ซึ่งนับเข้าโควตารายเดือนของ OA ระบบนับ push ทุกข้อความ และ follow-up จะหยุดเมื่อใช้โควตาถึง 80% เพื่อเก็บ 20% สุดท้ายไว้ให้ข้อความเรื่องการจอง เหตุฉุกเฉิน และข้อความสำรอง ส่วนการยกเลิกบนหน้าเว็บไม่ส่งข้อความเข้าแชท หน้าเว็บแสดงผลเอง และพนักงานเห็นนัดถูกยกเลิกพร้อมเหตุผล “ลูกค้ายกเลิกเองจากหน้าสัตว์เลี้ยงของฉัน” - การเลื่อนนัดยังไม่มีปุ่มของมันเอง ตอนนี้ลูกค้าต้องจองเวลาใหม่ก่อน แล้วค่อยยกเลิกนัดเดิม ถ้าใกล้เวลานัดเกินช่วงยกเลิกฟรี (ค่าเริ่มต้น 24 ชั่วโมง) หน้าเว็บจะไม่แสดงปุ่มยกเลิก แต่ให้ทักแชทร้านแทน และเซิร์ฟเวอร์ก็ปฏิเสธ (
late_cancellation) อยู่ดี - หน้าเว็บทำงานในเบราว์เซอร์ของ LINE ถ้าเปิดในเบราว์เซอร์ภายนอก ลูกค้าจะถูกส่งไป login LINE ก่อน และหน้าปิดตัวเองไม่ได้ จึงต้องบอกให้กลับไปที่ LINE เอง ตอนขึ้น production ครั้งแรก หน้า LIFF ขาวทั้งหน้าใน LINE เพราะ
/liff/*กับ/_next/*ยังติด basic auth ของเดโมอยู่ ต้องแก้ด้วยการยกเว้นสองเส้นทางนี้ - LINE บนคอมได้ประสบการณ์ที่ด้อยกว่า LINE desktop ไม่แสดง rich menu (เอกสารของ LINE: “Rich menus aren’t displayed on LINE for PC (macOS, Windows)”) ส่วน quick reply แสดงบน LINE desktop รุ่นปัจจุบัน (เจ้าของร้านเช็กเมื่อ 6 พฤศจิกายน) แต่ปุ่มเปิดหน้าเว็บส่งเป็นการ์ด ลูกค้าที่ใช้คอมจึงไปถึงหน้าเว็บทางการ์ดที่บอทแนบมา การกดการ์ดจะเปิดหน้าใน browser ของคอม (
liff.isInClient()เป็น false) โค้ดเดียวกันจะพาไป login LINE ก่อน (สแกน QR ด้วยมือถือ หรือใช้อีเมลและรหัสผ่าน) จองเสร็จแล้วหน้าปิดตัวเองไม่ได้ จึงขึ้นข้อความให้กลับไปที่แชทเอง แต่ข้อความยืนยันนัดยังเข้าแชทเหมือนเดิม การเปิดหน้าเว็บจากการ์ดบนคอมยังไม่ได้ทดสอบบน LINE desktop จริง - ผูกกับ LINE ให้น้อยที่สุด
apps/web/src/app/liff/_lib/liff.tsเป็นไฟล์เดียวที่รู้จัก LIFF เซิร์ฟเวอร์รับแค่ ID token ของ LINE Login ซึ่งได้มาจาก “Log in with LINE” แบบธรรมดาก็ได้ ถ้าวันหนึ่ง LINE คิดเงินค่า LIFF หรือเลิกให้บริการ ก็เปลี่ยนแค่วิธีที่หน้าเว็บได้ token มา ตอนนี้ LINE แนะนำให้สร้าง LIFF app ใหม่เป็น LINE MINI App ซึ่ง ROADMAP จดไว้ว่าต้องลองตอนตั้ง channel ของเดโมรอบหน้า - ปุ่มขึ้นอยู่กับโมเดลส่วนหนึ่ง บอทต้องเรียก tool หรือเขียนคำว่า “ปุ่ม…” ให้ตรงชื่อหน้า ถ้าไม่ทำทั้งสองอย่าง ลูกค้าก็ไม่ได้ปุ่ม แต่ช่องใน rich menu ยังอยู่เสมอ
สรุปเป็นข้อ
- งานที่ต้องเลือกหลายอย่างให้ทำบนหน้าเว็บ ให้บอทตอบคำถามแล้วส่งปุ่ม
- tool ที่เป็นทางผิดเดิมให้ถอดออก อย่าเก็บไว้ข้างทางใหม่
- ตัวอ้างอิงภายในที่ต้องส่งข้ามเทิร์นคือจุดอ่อน ให้ลูกค้ากดที่ของที่เขาเห็นแทน
- โมเดลบอกได้แค่ว่าต้องการหน้าไหน URL ต้องสร้างในโค้ดจาก channel ของแชท
- ถ้าข้อความสัญญาว่ามีปุ่ม โค้ดต้องทำให้มีปุ่มจริง
- หน้าเว็บส่งแค่ LIFF ID กับ ID token และตัวตนของลูกค้ามาจาก LINE กับฐานข้อมูลเท่านั้น
- ให้หน้าเว็บใช้ actor และกฎชุดเดียวกับบอท จะได้ทำได้ไม่เกินบอท
- จำกัดอัตราทั้งต่อ IP และต่อผู้ใช้ที่ตรวจแล้ว และ cache token ด้วย hash
- แยกโค้ดที่รู้จัก LIFF ไว้ในไฟล์เดียว
ที่มา
docs/decisions.md: “The bot sends the booking page instead of booking in chat (2026-11-02)”, “Customers cancel on the page, not in chat”, “สาขาและเวลาเปิด is a postback answered in code (2026-11-02)”, “Spam and abuse limits” (ตาราง LIFF API), “Cost and quota caps before the demo is public (2026-10-04)”docs/specs/M8.md(กฎ “The server knows nothing about LIFF”, provider เดียวกัน, rich menu แบบ reconcile),docs/specs/M9.md(กฎข้อ 2–6, “The confirmation in chat costs one push”),docs/specs/M9b.md(PublicActor)packages/pipeline/src/page-chain.ts,booking-page.ts(confirmFromPage,cancelFromPage),customer-portal.ts,cards.ts(pageButtonsCard),respond.ts,rich-menus.ts(syncRichMenus)packages/core/src/page-links.ts,booking-tools.ts(send_booking_link),engine.ts,prompt.tsapps/api/src/liff/rate-limit.ts,apps/api/src/liff/book/handle.ts,apps/api/src/worker/pollers.service.ts,packages/services/src/followups/schedule.ts(followupPushLimit)apps/web/next.config.ts(apiRewrites),apps/web/src/app/liff/_lib/liff.ts,apps/web/src/app/liff/[liffId]/packages/channels/src/rich-menu.ts(richMenuDefinitions)- commit
eb097a7(ปุ่มจองคิวก่อน,pagesPromisedInText, 25/25),859fdd1(การ์ด Flex), PR #25 (ยกเลิกนัดบนหน้าเว็บ) docs/STATUS.mdหัวข้อ “Before the demo is opened to visitors” ข้อ 1 (ต้นทุนต่อเทิร์น) และข้อ 2 (หน้า LIFF ขาวเพราะ basic auth),docs/ROADMAP.md(LINE MINI App)- บทความบั๊กที่ AI แชทบอททำจริง เรื่อง 7 และ 8