Skip to main content

ประตูรับ Webhook จาก LINE

ภาพรวม

POST /api/line/:id คือ endpoint เดียวที่ LINE Platform ยิงเข้ามาโดยตรงเมื่อมี event เกิดขึ้นกับ LINE OA ไม่ว่าจะเป็นข้อความ การกดปุ่ม การเพิ่มเพื่อน การบล็อก การเข้า/ออกกลุ่ม หรือ beacon พารามิเตอร์ :id คือ webhook id ที่ผูกกับ LINE OA หนึ่งตัว (คอลัมน์ line_oa.webhook_id)

สิ่งที่ต้องเข้าใจก่อนอ่านรายละเอียด: service นี้ ไม่ได้แยก handler ตาม event type แต่ทำหน้าที่เป็น router หรือตัวคัดกรองบาง ๆ ที่ถามคำถามเพียง 3 ข้อ แล้วส่งงานต่อ

  1. OA ตัวนี้ต้อง forward webhook ดิบไปยัง endpoint ของลูกค้าหรือไม่
  2. event นี้เข้าเงื่อนไข mbox (การส่งต่อให้เจ้าหน้าที่) หรือ postback พิเศษหรือไม่
  3. ถ้าไม่เข้าเงื่อนไขใดเลย จะ publish ทั้งก้อนลงคิว line_webhook ให้ worker-go ไปแยกแยะต่อ

ด้วยเหตุนี้ event ประเภท follow, unfollow, join, leave, beacon และ videoPlayComplete จึงไม่มีโค้ดจัดการอยู่ในโปรเจกต์นี้เลย ทั้งหมดตกลงสู่เส้นทาง default ไปยังคิว line_webhook ส่วน logic จริงเรื่อง auto-response, friend-track และตาราง line_user_friend อยู่ที่ฝั่ง worker-go ทั้งหมด

endpoint นี้ทำงานแบบ fire-and-forget โดยตอบ 200 พร้อม body {"code":"RES_SUCCESS_001","message":"OK"} กลับให้ LINE ทันที แล้วจึงประมวลผลใน goroutine เบื้องหลัง เนื่องจาก LINE มี timeout สั้นและจะ retry หากตอบช้า จึงจำเป็นต้องตอบกลับก่อนเสมอ

Business Flow

ขั้นรับ request (handler)

  1. อ่าน :id เป็น webhookId
  2. เก็บ header ทั้งหมดแล้วแปลง key เป็นตัวพิมพ์เล็ก เนื่องจาก LINE และ Express ส่งมาเป็นตัวพิมพ์เล็ก แต่ Go จะแปลงเป็นรูปแบบ Canonical จึงต้องแปลงกลับ มิฉะนั้นการ lookup x-line-signature จะพลาด
  3. อ่าน body พร้อมเก็บ bytes ดิบไว้ใน BodyRaw นอกเหนือจาก map ที่ parse แล้ว (ดูรายละเอียดที่ การส่งต่อ Raw Body และลายเซ็น)
  4. spawn goroutine เรียก Service.ProcessLine บน context.Background() โดยมี recover() ป้องกัน panic ทำให้ process ตาย แล้วตอบ 200 กลับทันที

ขั้นประมวลผล (ProcessLine)

  1. อ่าน config ด้วย HGETALL webhook_config:{webhookId} จาก Redis (ดู Redis Cache ของ Webhook Config)
    • cache miss (hash ว่าง) จะ publish payload ทั้งก้อนลงคิว line_webhook แล้วจบ โดย worker-go จะไปค้น config จากฐานข้อมูลเอง
    • อ่าน error จะ log แล้วหยุดทันที ไม่ publish ทำให้ข้อความหายไป ซึ่งเป็นพฤติกรรมเดียวกับระบบต้นฉบับ
  2. ถ้า config มี forwardWebhookUrl จะยิงต่อแบบไม่รอผล (ดู ส่งต่อ Webhook ไปยังระบบลูกค้า)
  3. คัด event ที่ type == "message" ออกมา ถ้า mboxEnabled == "1" และมี message event จะเข้าเส้นทาง mbox (ดู ส่งต่อให้เจ้าหน้าที่) เส้นทางนี้หากเข้าเงื่อนไขจะ return ทันทีโดยไม่ publish ลง line_webhook ซึ่งมีผลเป็นการปิดบอทชั่วคราว
  4. คัด event ที่ type == "postback" เมื่อ mboxEnabled == "1"
    • postback.data ขึ้นต้นด้วย mbox_team: จะ handoff เข้าทีมที่ผู้ใช้เลือก
    • postback.data ขึ้นต้นด้วย appt_cancel: จะเข้าสู่การยกเลิกนัดหมาย (ดู ยกเลิกนัดหมายผ่าน Postback)
  5. เส้นทาง default จะ publish payload ที่ประกอบด้วย webhookId, headers และ body ลงคิว line_webhook ในรูปแบบ bare JSON ให้ worker-go ประมวลผลบอทต่อ

:::note ข้อสังเกต ขั้นตอนที่ 7 และ 8 ตรวจสอบเฉพาะ event ตัวแรก ของแต่ละประเภทเท่านั้น (messageEvents[0] และ postbackEvents[0]) แม้ LINE จะส่งมาหลาย event ต่อหนึ่ง request ได้ ทั้งนี้เพื่อรักษา parity กับระบบ NestJS เดิม และหาก mbox ตัดสินใจ handoff แล้ว event อื่นใน batch เดียวกันจะถูกทิ้งทั้งหมด :::

ไฟล์และฟังก์ชันหลัก

MethodRouteAuthHandler
POST/api/line/:idไม่มีHandler.processLine
POST/api/Line/:idไม่มีHandler.processLine (ตัวเดียวกัน)

ระบบลงทะเบียนทั้ง 2 รูปแบบ เนื่องจาก NestJS และ Express จับคู่ path แบบไม่สนใจตัวพิมพ์ (ต้นฉบับประกาศเป็น @Controller('Line')) ในขณะที่ gin เป็น case-sensitive และ traffic จริงจาก LINE ยิงเข้ามาเป็นตัวพิมพ์เล็ก

โค้ดทั้งหมดอยู่ในแพ็กเกจ internal/line/

ไฟล์ฟังก์ชันสำคัญ
internal/line/handler.goRegister, Handler.processLine, toLowerHeader, decodeJSONObject
internal/line/service.goService.ProcessLine (dispatcher หลัก), eventList, eventType, sourceUserID, messageText, postbackData, Service.publish, parseLineOaID, splitSecond

Type สำคัญคือ LineWebhookPayload ซึ่งประกอบด้วย webhookId, headers และ body โดยมี MarshalJSON แบบ custom ที่ปล่อย bytes ดิบออกไปเป็นฟิลด์ body

จุดเชื่อมต่อกับ Service อื่น

  • Redis — อ่าน webhook_config:{webhookId} แบบอ่านอย่างเดียว และอ่าน/เขียน agent_mode:{lineOaId}:{userId} ผ่าน internal/cache.Cache ที่เติม prefix LINE_MANAGEMENT: ให้อัตโนมัติ
  • RabbitMQ — exchange line_exchange พร้อมคิว line_webhook, mbox_handoff และ booking_notification โดยใช้ชื่อคิวเป็น routing key และส่งเป็น bare JSON
  • ไม่มีการเชื่อมต่อฐานข้อมูล — โมดูล line ไม่แตะ PostgreSQL เลย ทุกอย่างมาจาก Redis cache
  • worker-go — เป็นผู้ consume คิว line_webhook แล้วไปทำ auto-response, friend-track และบันทึกข้อมูลลงตาราง line_user กับ line_user_friend
  • cms-api — โมดูล line-oa-management เป็นผู้เขียน webhook_config:* ลง Redis เมื่อมีการแก้ไข config ของ OA