บทเรียน

สอนเขียน LINE Bot ด้วย Node.js และ Python (Messaging API) 2026

สอนเขียน LINE Bot ทีละขั้นด้วย Messaging API ตั้งแต่เปิดใช้ channel ตรวจลายเซ็น webhook ตอบด้วย Flex Message และ quick reply ทำขั้นตอนจองคิวด้วย postback เพิ่มคำตอบจาก AI ที่จำกัดอยู่ใน FAQ ของร้าน ไปจนถึง deploy พร้อมโค้ด Node.js (@line/bot-sdk v11) และ Python (line-bot-sdk v3)

LineBot.pro Team· อัปเดต 19 นาทีในการอ่าน
สอนเขียน LINE Bot ด้วย Node.js และ Python (Messaging API) 2026

#LINE Bot ทำงานอย่างไร

LINE Bot คือเว็บเซอร์วิสที่รับ webhook event จาก LINE Platform แล้วตอบกลับผ่าน Messaging API ถ้าจะเขียน LINE Bot ในปี 2026 ต้องมี 3 อย่าง คือ LINE OA ที่เปิดใช้ Messaging API แล้ว, endpoint แบบ HTTPS ที่ตรวจสอบ header x-line-signature และโค้ดที่ตอบกลับด้วย reply token ของ event นั้น บทความนี้จะพาเขียนบอทที่ใช้งานได้จริงทั้งด้วย Node.js (TypeScript, @line/bot-sdk v11) และ Python (line-bot-sdk v3) ตั้งแต่บอทตอบทวนข้อความ ปุ่มกด ขั้นตอนจองคิวแบบง่าย การให้ AI ตอบจาก FAQ ของร้านเท่านั้น ไปจนถึงเช็กลิสต์ก่อน deploy

ไดอะแกรม
ผู้ใช้ ── ข้อความ ──▶ LINE Platform ── webhook (POST + x-line-signature) ──▶ เซิร์ฟเวอร์ของคุณ
                                                                              │
ผู้ใช้ ◀── คำตอบ ─── LINE Platform ◀──── Messaging API (reply หรือ push) ─────┘
  1. ผู้ใช้ส่งข้อความ กดปุ่ม หรือเพิ่มบัญชีของคุณเป็นเพื่อน
  2. LINE Platform ส่ง HTTPS POST ที่มี event อย่างน้อย 1 รายการไปยัง webhook URL
  3. เซิร์ฟเวอร์ตรวจลายเซ็น ตอบ 200 แล้วจึงประมวลผล event
  4. ตอบกลับด้วย reply API (ใช้ reply token ของ event) หรือส่งภายหลังด้วย push (ใช้ user ID)

ข้อความแบบ reply ไม่นับรวมในโควตาข้อความรายเดือนของแพ็กเกจ ส่วน push, multicast, broadcast และ narrowcast จะนับ 1 ข้อความต่อผู้รับ 1 คน

#คุณกำลังสร้างบอทแบบไหน

ประเภทหลักการเหมาะกับข้อควรระวัง
บอทคีย์เวิร์ดเจอคำที่กำหนดในข้อความแล้วส่งคำตอบที่เตรียมไว้คำถามที่พบบ่อย เวลาเปิดปิด ราคาไม่เข้าใจคำถามที่พิมพ์ต่างจากที่ตั้งไว้
บอทเมนู / postbackปุ่ม quick reply และ Rich Menu ส่งข้อมูล postback มาที่เซิร์ฟเวอร์จองคิว สั่งสินค้า กรอกข้อมูลทีละขั้นต้องออกแบบขั้นตอนตั้งแต่แรก
บอทที่ใช้ AI ช่วยโมเดลภาษาตอบข้อความที่พิมพ์อิสระคำถามหลากหลาย หลายภาษาต้องจำกัดให้ตอบจากข้อมูลของร้าน และมีคนรับช่วงต่อ
แบบผสมปุ่มสำหรับงานหลัก AI สำหรับคำถามอื่น และพนักงานสำหรับกรณีพิเศษธุรกิจส่วนใหญ่มีเส้นทางให้ทดสอบมากขึ้น

โค้ดในบทความนี้เป็นบอทแบบผสมของร้านทำผม การจองคิวทำผ่านปุ่ม AI ตอบเฉพาะจาก FAQ ของร้าน และปุ่ม "ติดต่อพนักงาน" จะส่งต่อแชทให้คนจริงตอบ

#สิ่งที่ต้องเตรียมและการตั้งค่า channel

#สิ่งที่ต้องมี

  • LINE OA (สมัครฟรี) ที่เปิดใช้ Messaging API แล้ว
  • Node.js 22 ขึ้นไป (เป็นข้อกำหนดของ @line/bot-sdk v11) หรือ Python 3.10 ขึ้นไป (line-bot-sdk v3)
  • เซิร์ฟเวอร์ที่เข้าถึงได้ผ่าน HTTPS และถ้าทดสอบบนเครื่องตัวเอง ให้ใช้ tunnel เช่น ngrok หรือ Cloudflare Tunnel

#ขั้นตอนที่ 1: สร้าง LINE OA และเปิดใช้ Messaging API

ตั้งแต่วันที่ 4 กันยายน 2024 ไม่สามารถสร้าง Messaging API channel โดยตรงใน LINE Developers Console ได้แล้ว ให้ทำตามนี้แทน

  1. สร้าง LINE OA โดยสมัคร Business ID แล้วกรอกแบบฟอร์ม ดูขั้นตอนละเอียดได้ในคู่มือสมัคร LINE OA
  2. ใน LINE Official Account Manager ไปที่ Settings → Messaging API แล้วเปิดใช้ Messaging API
  3. เลือก provider อย่างรอบคอบ เพราะเปลี่ยนภายหลังไม่ได้ และผู้ใช้คนเดียวกันจะมี user ID ต่างกันในแต่ละ provider จึงควรเก็บ channel ทั้งหมดของธุรกิจเดียวกันไว้ใน provider เดียว

จากนั้น Messaging API channel ใหม่จะปรากฏใน LINE Developers Console

#ขั้นตอนที่ 2: เก็บข้อมูลยืนยันตัวตน

ข้อมูลอยู่ที่ไหนใช้ทำอะไร
Channel secretแท็บ Basic settings ใน Consoleตรวจลายเซ็นของ webhook
Channel access tokenแท็บ Messaging API ใน Console หรือออกผ่าน APIเรียกใช้ Messaging API

Channel access token มี 4 แบบ วิธีที่เร็วที่สุดสำหรับเริ่มทดสอบคือ long-lived token (ไม่มีวันหมดอายุ ออกได้ 1 อันต่อ channel ใน Console) สำหรับระบบจริง LINE แนะนำ channel access token v2.1 ที่กำหนดอายุเองได้ (สูงสุด 30 วัน) ซึ่งขอด้วย JSON Web Token ที่เซ็นด้วยคีย์ของคุณเอง นอกจากนี้ยังมี short-lived token (30 วัน) และ stateless token (15 นาที)

#ขั้นตอนที่ 3: ตั้งค่า channel

  • Webhook URL: endpoint ของคุณ เช่น https://bot.example.com/webhook ต้องเป็น HTTPS ที่ใช้ใบรับรองจากผู้ออกใบรับรองที่เบราว์เซอร์ทั่วไปเชื่อถือ ใช้ใบรับรองแบบ self-signed ไม่ได้ และ 1 channel ตั้ง webhook URL ได้เพียง 1 URL
  • กด Verify แล้วเปิด Use webhook
  • ถ้าให้โค้ดเป็นผู้ตอบ ให้ปิดข้อความทักทายและข้อความตอบกลับอัตโนมัติในหน้าการตั้งค่าการตอบกลับของ LINE Official Account Manager เพราะทั้งสองอย่างเปิดไว้เป็นค่าเริ่มต้นและจะตอบซ้ำกับบอท ถ้าต้องการให้พนักงานตอบแชทเองได้ ให้เปิดแชทไว้

#ขั้นตอนที่ 4: สร้างโปรเจกต์

Node.js (TypeScript):

bash
mkdir line-bot && cd line-bot
npm init -y && npm pkg set type=module
npm install @line/bot-sdk express
npm install -D typescript tsx @types/express @types/node

เพิ่ม scripts ใน package.json และสร้างไฟล์ tsconfig.json

json
{
  "scripts": {
    "dev": "tsx watch --env-file=.env src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}
json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "skipLibCheck": true
  },
  "include": ["src"]
}

Python:

bash
mkdir line-bot && cd line-bot
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install line-bot-sdk flask

เก็บข้อมูลยืนยันตัวตนไว้ในไฟล์ .env และห้าม commit ขึ้น repository ฝั่ง Node โหลดไฟล์นี้ด้วย --env-file ส่วน Python ให้ export ตัวแปรใน shell หรือใช้ระบบเก็บ secret ของโฮสต์

bash
LINE_CHANNEL_SECRET=your_channel_secret
LINE_CHANNEL_ACCESS_TOKEN=your_channel_access_token
PORT=3000

#เขียน webhook: รับและตอบกลับ

#Node.js (TypeScript, @line/bot-sdk v11)

ts
// src/index.ts
import express from "express"
import {
  JSONParseError,
  LineBotClient,
  SignatureValidationFailed,
  middleware,
  webhook,
} from "@line/bot-sdk"

const channelSecret = process.env.LINE_CHANNEL_SECRET
const channelAccessToken = process.env.LINE_CHANNEL_ACCESS_TOKEN
if (!channelSecret || !channelAccessToken) {
  throw new Error("กรุณาตั้งค่า LINE_CHANNEL_SECRET และ LINE_CHANNEL_ACCESS_TOKEN")
}

const client = LineBotClient.fromChannelAccessToken({ channelAccessToken })
const app = express()

// middleware ของ LINE ตรวจ x-line-signature จาก body ดิบ
// จึงต้องวางไว้ก่อนตัวแปลง JSON ทุกตัว
app.post("/webhook", middleware({ channelSecret }), (req, res) => {
  res.sendStatus(200) // ตอบ LINE ก่อน แล้วค่อยประมวลผล
  const { events } = req.body as webhook.CallbackRequest
  for (const event of events) {
    handleEvent(event).catch((err) => console.error("ประมวลผล event ไม่สำเร็จ", err))
  }
})

app.use(express.json()) // route อื่นแปลง JSON ได้ตามปกติ

async function handleEvent(event: webhook.Event) {
  if (event.type !== "message" || event.message.type !== "text" || !event.replyToken) return

  await client.replyMessage({
    replyToken: event.replyToken,
    messages: [{ type: "text", text: `คุณพิมพ์ว่า: ${event.message.text}` }],
  })
}

// middleware แค่ส่ง error ต่อให้ next() ถ้าไม่มี handler นี้ Express จะตอบ 500
app.use((err: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
  if (err instanceof SignatureValidationFailed) {
    res.status(401).send("Invalid signature")
    return
  }
  if (err instanceof JSONParseError) {
    res.status(400).send("Invalid JSON")
    return
  }
  next(err)
})

const port = Number(process.env.PORT ?? 3000)
app.listen(port, () => console.log(`LINE Bot รออยู่ที่พอร์ต ${port}`))

รันด้วย npm run dev เปิดพอร์ต 3000 ผ่าน tunnel นำ URL แบบ HTTPS ต่อท้ายด้วย /webhook ไปใส่ใน Console แล้วกด Verify จากนั้นลองส่งข้อความหาบัญชีจากมือถือ บอทจะตอบทวนข้อความกลับมา

จุดที่มักพลาด:

  • วาง middleware ไว้ก่อน middleware() ต้องใช้ body ดิบเพื่อคำนวณลายเซ็น HMAC-SHA256 ใหม่ ถ้า express.json() ทำงานก่อน การตรวจจะไม่ผ่าน อ่านรายละเอียดได้ในคู่มือ @line/bot-sdk Express middleware
  • middleware ไม่ได้ตอบ 401 เอง มันส่ง SignatureValidationFailed หรือ JSONParseError ต่อให้ next() ถ้าไม่มี error handler Express จะตอบ 500
  • ส่ง request object เดียวต่อการเรียก 1 ครั้ง ใน SDK v11 ทุกเมธอดรับ object เดียว เช่น replyMessage({ replyToken, messages }) คลาส Client แบบเดิมและ client.replyMessage(token, message) ถูกลบออกใน v11.0.0 (เมษายน 2026)
  • ตอบ 200 ก่อน แล้วค่อยทำงาน LINE แนะนำให้ประมวลผล event แบบ asynchronous เซิร์ฟเวอร์ที่รันตลอดเวลาตอบก่อนได้ไม่มีปัญหา แต่บนแพลตฟอร์ม serverless ที่หยุดฟังก์ชันทันทีหลังส่ง response ให้ทำงานให้เสร็จก่อนตอบ หรือส่งต่อเข้า queue
  • reply token ใช้ได้ครั้งเดียว และ LINE ระบุให้ใช้ภายใน 1 นาทีหลังได้รับ webhook
  • event ว่าง LINE อาจส่ง {"destination": "...", "events": []} มาเพื่อตรวจการเชื่อมต่อ (ปุ่ม Verify ก็ส่งแบบนี้) ให้ตอบ 200

#Python (line-bot-sdk v3, Flask)

python
# app.py
import os

from flask import Flask, abort, request
from linebot.v3 import WebhookHandler
from linebot.v3.exceptions import InvalidSignatureError
from linebot.v3.messaging import (
    ApiClient,
    Configuration,
    MessagingApi,
    ReplyMessageRequest,
    TextMessage,
)
from linebot.v3.webhooks import MessageEvent, TextMessageContent

app = Flask(__name__)
configuration = Configuration(access_token=os.environ["LINE_CHANNEL_ACCESS_TOKEN"])
handler = WebhookHandler(os.environ["LINE_CHANNEL_SECRET"])


@app.post("/webhook")
def webhook():
    signature = request.headers.get("X-Line-Signature", "")
    body = request.get_data(as_text=True)  # body ดิบ ก่อนแปลงเป็น JSON
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)
    return "OK"


@handler.add(MessageEvent, message=TextMessageContent)
def handle_text(event):
    with ApiClient(configuration) as api_client:
        MessagingApi(api_client).reply_message(
            ReplyMessageRequest(
                reply_token=event.reply_token,
                messages=[TextMessage(text=f"คุณพิมพ์ว่า: {event.message.text}")],
            )
        )


if __name__ == "__main__":
    app.run(port=int(os.environ.get("PORT", 3000)))

export ตัวแปรทั้งสองตัว แล้วรันด้วย python app.py เมธอด WebhookHandler.handle() ตรวจลายเซ็นจาก body ดิบ และโยน InvalidSignatureError เมื่อไม่ตรงกัน ให้ import จาก linebot.v3 เสมอ เพราะ LineBotApi ของเวอร์ชัน 2.x เลิกแนะนำแล้ว และมีเพียงโมดูล linebot.v3 ที่ยังได้รับการดูแล

#ประเภทข้อความและปุ่ม

ทุกตัวอย่างด้านล่างเป็น message object ที่ใส่ใน messages ได้ สูงสุด 5 ข้อความต่อการ reply หรือ push 1 ครั้ง

ts
import { messagingApi } from "@line/bot-sdk"

const text: messagingApi.TextMessage = {
  type: "text",
  text: "สวัสดีค่ะ วันนี้ให้ร้านช่วยเรื่องอะไรดีคะ", // ไม่เกิน 5,000 ตัวอักษร
}

const image: messagingApi.ImageMessage = {
  type: "image",
  originalContentUrl: "https://example.com/price-list.jpg", // HTTPS, JPEG หรือ PNG, ไม่เกิน 10 MB
  previewImageUrl: "https://example.com/price-list-preview.jpg", // ไม่เกิน 1 MB
}

const card: messagingApi.FlexMessage = {
  type: "flex",
  altText: "ตัดผม ฿450", // แสดงในการแจ้งเตือนและรายการแชท
  contents: {
    type: "bubble",
    hero: {
      type: "image",
      url: "https://example.com/haircut.jpg",
      size: "full",
      aspectRatio: "20:13",
      aspectMode: "cover",
    },
    body: {
      type: "box",
      layout: "vertical",
      contents: [
        { type: "text", text: "ตัดผม", weight: "bold", size: "xl" },
        { type: "text", text: "฿450 · 45 นาที", color: "#06C755" },
      ],
    },
    footer: {
      type: "box",
      layout: "vertical",
      contents: [
        {
          type: "button",
          style: "primary",
          color: "#06C755",
          action: { type: "postback", label: "จองคิว", data: "action=service&id=cut", displayText: "ขอจองคิวตัดผม" },
        },
      ],
    },
  },
}

const menu: messagingApi.TextMessage = {
  type: "text",
  text: "ต้องการทำรายการอะไรคะ",
  quickReply: {
    items: [
      // ได้สูงสุด 13 ปุ่ม
      { type: "action", action: { type: "postback", label: "จองคิว", data: "action=start_booking", displayText: "จองคิว" } },
      { type: "action", action: { type: "message", label: "เวลาเปิดร้าน", text: "เวลาเปิดร้าน" } },
      { type: "action", action: { type: "location", label: "ส่งตำแหน่งของฉัน" } },
    ],
  },
}
  • ข้อความตัวอักษร ยาวได้ไม่เกิน 5,000 ตัวอักษร
  • รูปภาพ ต้องใช้ URL แบบ HTTPS รูปต้นฉบับไม่เกิน 10 MB (JPEG หรือ PNG) และรูปตัวอย่างไม่เกิน 1 MB
  • Flex Message คือเลย์เอาต์ที่เขียนด้วย JSON (bubble เดียว หรือ carousel หลาย bubble) ส่วน altText คือข้อความที่แสดงในการแจ้งเตือน ทดลองเลย์เอาต์ได้ใน Flex Message Simulator ของ LINE
  • Quick reply แสดงปุ่มเหนือแป้นพิมพ์ได้สูงสุด 13 ปุ่ม และ action แบบกล้อง คลังรูป และตำแหน่ง ใช้ได้เฉพาะใน quick reply
  • Postback action ส่ง postback event ที่มี data ยาวไม่เกิน 300 ตัวอักษรมาที่เซิร์ฟเวอร์ โดยผู้ใช้ไม่ต้องพิมพ์ ส่วน displayText คือข้อความที่แสดงในแชทเหมือนผู้ใช้พิมพ์เอง

การเขียน Flex JSON เองใช้เวลามาก ถ้าอยากเริ่มจากการ์ดที่ทำเสร็จแล้ว ใช้เครื่องมือสร้าง Rich Message ด้วย AI ของ LineBot.pro สร้าง Flex bubble (รูปหลัก ข้อความ และปุ่ม 1 ปุ่ม) แล้วคัดลอก JSON ไปส่งด้วยโค้ดข้างบนได้เลย ส่วนเมนูถาวรใต้ห้องแชท ดูวิธีสร้าง Rich Menu ของ LINE

#ออกแบบลำดับการสนทนา: postback, สถานะ และการส่งต่อให้พนักงาน

การจองคิวมีหลายขั้นตอน บอทจึงต้องจำว่าผู้ใช้แต่ละคนทำถึงขั้นไหนแล้ว ใช้ handleEvent ด้านล่างแทนเวอร์ชันตอบทวนใน src/index.ts (เพิ่ม messagingApi ใน import ของ @line/bot-sdk ด้วย) โค้ดนี้ทักทายเพื่อนใหม่ จองคิว 3 ขั้นด้วย postback และตัวเลือกวันที่ และให้ผู้ใช้เปลี่ยนไปคุยกับพนักงานได้

ts
type Session =
  | { step: "idle" }
  | { step: "pick_date"; service: string }
  | { step: "confirm"; service: string; date: string }
  | { step: "human" } // พนักงานรับช่วงแชทนี้แล้ว

// เก็บในหน่วยความจำเพื่อการสาธิต ข้อมูลจะหายเมื่อรีสตาร์ตและไม่แชร์ระหว่างหลาย instance
// ระบบจริงให้ใช้ Redis หรือฐานข้อมูล
const sessions = new Map<string, Session>()

const SERVICES: Record<string, string> = { cut: "ตัดผม", color: "ทำสีผม" }

async function handleEvent(event: webhook.Event) {
  // บทความนี้รองรับเฉพาะแชท 1:1
  if (event.source?.type !== "user" || !event.source.userId) return
  const userId = event.source.userId

  if (event.type === "follow") {
    await reply(event.replyToken, [menuMessage("ยินดีต้อนรับค่ะ ขอบคุณที่เพิ่มร้านเป็นเพื่อน")])
    return
  }

  const session = sessions.get(userId) ?? { step: "idle" }
  if (session.step === "human") return // พนักงานตอบจาก LINE Official Account Manager

  if (event.type === "postback" && event.replyToken) {
    await onPostback(userId, session, event.postback, event.replyToken)
    return
  }

  if (event.type === "message" && event.replyToken) {
    if (event.message.type !== "text") {
      await reply(event.replyToken, [menuMessage("ขอบคุณค่ะ ตอนนี้บอทอ่านได้เฉพาะข้อความตัวอักษร")])
      return
    }
    const text = event.message.text.trim()
    if (["staff", "พนักงาน"].includes(text.toLowerCase())) {
      sessions.set(userId, { step: "human" })
      await reply(event.replyToken, [
        { type: "text", text: "ได้เลยค่ะ พนักงานจะตอบในแชทนี้ระหว่างเวลาทำการ (10:00-20:00)" },
      ])
      return
    }
    await reply(event.replyToken, [menuMessage("กรุณาเลือกรายการด้านล่างค่ะ")]) // หัวข้อ AI จะมาแทนบรรทัดนี้
  }
}

async function onPostback(userId: string, session: Session, postback: webhook.PostbackContent, replyToken: string) {
  const data = new URLSearchParams(postback.data)
  const action = data.get("action")

  if (action === "start_booking") {
    await reply(replyToken, [{
      type: "text",
      text: "ต้องการจองบริการไหนคะ",
      quickReply: {
        items: Object.entries(SERVICES).map(([id, label]) => ({
          type: "action" as const,
          action: { type: "postback" as const, label, data: `action=service&id=${id}`, displayText: label },
        })),
      },
    }])
    return
  }

  if (action === "service") {
    const service = SERVICES[data.get("id") ?? ""]
    if (!service) return
    sessions.set(userId, { step: "pick_date", service })
    await reply(replyToken, [{
      type: "text",
      text: `${service}: สะดวกวันไหนคะ`,
      quickReply: {
        items: [{ type: "action", action: { type: "datetimepicker", label: "เลือกวันที่", data: "action=date", mode: "date" } }],
      },
    }])
    return
  }

  if (action === "date" && session.step === "pick_date" && postback.params?.date) {
    const next: Session = { step: "confirm", service: session.service, date: postback.params.date }
    sessions.set(userId, next)
    await reply(replyToken, [{
      type: "text",
      text: `${next.service} วันที่ ${next.date} ส่งคำขอจองเลยไหมคะ`,
      quickReply: {
        items: [
          { type: "action", action: { type: "postback", label: "ยืนยัน", data: "action=confirm", displayText: "ยืนยัน" } },
          { type: "action", action: { type: "postback", label: "ยกเลิก", data: "action=cancel", displayText: "ยกเลิก" } },
        ],
      },
    }])
    return
  }

  if (action === "confirm" && session.step === "confirm") {
    sessions.delete(userId)
    // บันทึกคำขอลงระบบจองคิว หรือแจ้งพนักงานตรงนี้
    await reply(replyToken, [
      { type: "text", text: `ได้รับคำขอแล้วค่ะ: ${session.service} วันที่ ${session.date} ร้านจะยืนยันเวลาในแชทนี้` },
    ])
    return
  }

  if (action === "cancel") {
    sessions.delete(userId)
    await reply(replyToken, [menuMessage("ยกเลิกแล้วค่ะ มีอะไรให้ช่วยอีกไหมคะ")])
  }
}

function menuMessage(text: string): messagingApi.TextMessage {
  return {
    type: "text",
    text,
    quickReply: {
      items: [
        { type: "action", action: { type: "postback", label: "จองคิว", data: "action=start_booking", displayText: "จองคิว" } },
        { type: "action", action: { type: "message", label: "ติดต่อพนักงาน", text: "พนักงาน" } },
      ],
    },
  }
}

async function reply(replyToken: string, messages: messagingApi.Message[]) {
  await client.replyMessage({ replyToken, messages })
}

ประเด็นสำคัญ:

  • ข้อมูล postback คือโปรโตคอลที่คุณกำหนดเอง แยกค่า action=service&id=cut ด้วย URLSearchParams ให้สั้นไม่เกิน 300 ตัวอักษร และอย่าเชื่อข้อมูลโดยไม่ตรวจ ต้องเช็กว่าขั้นตอนและค่าที่ได้สมเหตุสมผล
  • ตัวเลือกวันที่ คือ action แบบ datetimepicker วันที่ที่เลือกจะอยู่ใน postback.params.date (รูปแบบ YYYY-MM-DD ในโหมด date)
  • ทุก event ต้องได้รับการตอบสนอง เมื่อผู้ใช้ส่งสติกเกอร์ รูป หรือตำแหน่ง ให้พากลับไปที่เมนู แทนที่จะเงียบหรือโค้ดพัง ส่วนแชทกลุ่มมีประเภทแหล่งที่มาต่างออกไป จึงข้ามไว้ในตัวอย่างนี้
  • ระบบจริงต้องเก็บสถานะนอกหน่วยความจำ ใช้ user ID เป็นคีย์ และตั้งเวลาหมดอายุให้การจองที่ค้างไว้
  • การส่งต่อให้พนักงาน เมื่อผู้ใช้กด "ติดต่อพนักงาน" บอทจะหยุดตอบผู้ใช้คนนั้น และพนักงานตอบผ่านแชทใน LINE Official Account Manager (ต้องเปิดแชทไว้ในการตั้งค่าการตอบกลับ) และควรมีวิธีส่งแชทคืนให้บอท เช่น คำสั่งของแอดมิน ปุ่มในระบบหลังบ้าน หรือการหมดเวลาอัตโนมัติ
  • ใส่เมนูหลักไว้ใน Rich Menu เพื่อให้ผู้ใช้แทบไม่ต้องพิมพ์

ถ้าใช้ Python (line-bot-sdk v3) จัดการ postback แบบเดียวกันได้โดยเพิ่มโค้ดนี้ใน app.py

python
from urllib.parse import parse_qsl

from linebot.v3.webhooks import PostbackEvent


@handler.add(PostbackEvent)
def handle_postback(event):
    data = dict(parse_qsl(event.postback.data))  # "action=service&id=cut"
    if data.get("action") == "date" and event.postback.params:
        text = f"วันที่เลือก: {event.postback.params['date']}"
    else:
        text = f"action: {data.get('action')}"
    with ApiClient(configuration) as api_client:
        MessagingApi(api_client).reply_message(
            ReplyMessageRequest(reply_token=event.reply_token, messages=[TextMessage(text=text)])
        )

#เพิ่มคำตอบจาก AI (ตัวอย่างด้วย Gemini)

โมเดลภาษาช่วยตอบคำถามที่ปุ่มครอบคลุมไม่ถึง เช่น "ตัดผมเด็กได้ไหม" หรือ "มีที่จอดรถไหม" แต่ก็เสี่ยงที่จะแต่งราคาหรือให้สัญญาที่ร้านไม่ได้ให้ไว้ หลัก 3 ข้อนี้ช่วยให้ใช้งานได้อย่างปลอดภัย

  1. ให้ข้อมูลของร้านเท่านั้น และสั่งให้ตอบ NO_ANSWER เมื่อข้อมูลไม่ครอบคลุมคำถาม
  2. ส่งต่อให้พนักงาน แทนการเดา
  3. ทำให้เสร็จภายในเวลาของ reply token LINE ระบุให้ใช้ reply token ภายใน 1 นาที จึงควรจำกัดเวลาเรียกโมเดล (ตัวอย่างนี้ 15 วินาที) ถ้างานใช้เวลานานกว่านั้น ให้ตอบ "รอสักครู่นะคะ" แล้วส่งผลลัพธ์ภายหลังด้วย push message ซึ่งนับรวมในโควตาข้อความรายเดือน

ตัวอย่างนี้ใช้ Gen AI SDK ของ Google (npm install @google/genai) และอ่านชื่อโมเดลจาก environment variable เพราะเวอร์ชันโมเดลเปลี่ยนอยู่เรื่อย ๆ ให้เลือกโมเดลปัจจุบันจากเอกสารของ Google

ts
// src/ai.ts
import { GoogleGenAI } from "@google/genai"

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY })
const model = process.env.GEMINI_MODEL // ชื่อโมเดลปัจจุบันจากเอกสารของ Google
if (!model) throw new Error("กรุณาตั้งค่า GEMINI_MODEL")

// ใส่เฉพาะข้อมูลที่ยอมให้บอทพูดซ้ำได้ทุกคำ
const FAQ = `
เวลาเปิดร้าน: 10:00-20:00 ปิดทุกวันพุธ
ที่อยู่: 12 ซอยสุขุมวิท 24 กรุงเทพฯ (BTS พร้อมพงษ์)
ตัดผม: ฿450 ประมาณ 45 นาที ทำสีผม: เริ่มต้น ฿1,500 ประมาณ 2 ชั่วโมง
จองคิว: กด "จองคิว" ในเมนู เปลี่ยนเวลาได้ก่อนนัด 3 ชั่วโมง
`

export async function answerFromFaq(question: string): Promise<string | null> {
  const response = await ai.models.generateContent({
    model: model!,
    contents: question,
    config: {
      systemInstruction:
        "คุณคือผู้ช่วยตอบคำถามลูกค้าของร้านทำผม ให้ตอบโดยใช้ข้อมูลใน FAQ ด้านล่างเท่านั้น " +
        "ถ้า FAQ ไม่มีคำตอบ ให้ตอบว่า NO_ANSWER เท่านั้น " +
        "ตอบเป็นภาษาเดียวกับลูกค้า ไม่เกิน 3 ประโยคสั้น ๆ ห้ามแต่งราคา เวลา หรือโปรโมชันขึ้นเอง\n\n" +
        `FAQ:\n${FAQ}`,
      temperature: 0.2,
      maxOutputTokens: 300,
      abortSignal: AbortSignal.timeout(15_000), // ให้เสร็จก่อน reply token หมดเวลา
    },
  })
  const text = response.text?.trim()
  if (!text || text.includes("NO_ANSWER")) return null
  return text.slice(0, 5000) // ข้อความตัวอักษรของ LINE ยาวได้ไม่เกิน 5,000 ตัวอักษร
}

จากนั้นใน handleEvent ให้แทนบรรทัดสุดท้ายของส่วนที่จัดการข้อความตัวอักษรด้วยโค้ดนี้

ts
// ด้านบนของ src/index.ts (ES modules ต้องใส่นามสกุล .js)
import { answerFromFaq } from "./ai.js"

// ใน handleEvent แทนการส่งเมนูทุกครั้งที่ผู้ใช้พิมพ์อิสระ:
await client.showLoadingAnimation({ chatId: userId, loadingSeconds: 20 })
const answer = await answerFromFaq(text).catch(() => null)
await reply(event.replyToken, [
  answer
    ? { type: "text", text: answer }
    : menuMessage("ขออภัยค่ะ เรื่องนี้บอทยังตอบไม่ได้ กด “ติดต่อพนักงาน” เพื่อคุยกับพนักงานได้เลย"),
])

แอนิเมชันกำลังโหลดจะแสดงในแชท 1:1 ได้ 5 ถึง 60 วินาที (ทีละ 5 วินาที) และหายไปทันทีที่คำตอบของคุณส่งถึง

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

#Deploy และการติดตามระบบ

#Docker image

dockerfile
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

ใช้โฮสต์ไหนก็ได้ที่รัน container หรือโปรเซส Node.js หลัง HTTPS ได้ ส่ง channel secret, access token และคีย์ AI เป็น environment variable หรือ secret ห้ามฝังไว้ใน image

#ชี้ LINE มาที่ URL จริง

  1. ใน LINE Developers Console แท็บ Messaging API ตั้ง webhook URL เป็น https://your-domain/webhook
  2. กด Verify ซึ่ง LINE จะส่ง event ว่างมา และเซิร์ฟเวอร์ต้องตอบ 200
  3. ตรวจว่าเปิด Use webhook แล้ว จากนั้นทดสอบจากมือถือ

#event ซ้ำ การส่งซ้ำ และ log

  • การส่ง webhook ซ้ำ (redelivery) ปิดไว้เป็นค่าเริ่มต้น ถ้าเปิดใน Console แล้ว LINE จะส่ง webhook ที่ไม่ได้รับคำตอบ 2xx ซ้ำ โดยไม่เปิดเผยจำนวนครั้งและช่วงเวลา
  • แม้ไม่เปิดก็อาจได้ event ซ้ำ ให้ข้าม event ที่ webhookEventId เคยประมวลผลแล้ว ส่วน event ที่ถูกส่งซ้ำจะมี deliveryContext.isRedelivery: true
  • เขียน log เป็น JSON บรรทัดละรายการ พร้อม event ID เพื่อไล่ดูบทสนทนาเดียวได้
  • รู้ขีดจำกัด endpoint ส่วนใหญ่ของ Messaging API รับได้ 2,000 request ต่อวินาทีต่อ channel, multicast 200 ต่อวินาที, broadcast และ narrowcast 60 ต่อชั่วโมง ถ้าได้ 429 แปลว่าชน rate limit หรือโควตาข้อความรายเดือนหมด
ts
import { HTTPFetchError } from "@line/bot-sdk"

const seen = new Map<string, number>() // webhookEventId -> เวลาที่ได้รับ ถ้ามีหลาย instance ให้ใช้ Redis (SET NX + TTL)

function firstDelivery(event: webhook.Event): boolean {
  const now = Date.now()
  for (const [id, at] of seen) if (now - at > 30 * 60_000) seen.delete(id)
  if (seen.has(event.webhookEventId)) return false
  seen.set(event.webhookEventId, now)
  return true
}

function log(level: "info" | "error", msg: string, data: Record<string, unknown> = {}) {
  console.log(JSON.stringify({ time: new Date().toISOString(), level, msg, ...data }))
}

app.post("/webhook", middleware({ channelSecret }), (req, res) => {
  res.sendStatus(200)
  const { events } = req.body as webhook.CallbackRequest
  for (const event of events) {
    if (!firstDelivery(event)) continue // event เดียวกันมาถึงสองครั้ง
    handleEvent(event).catch((err) => {
      if (err instanceof HTTPFetchError) {
        // 429: ชน rate limit หรือโควตารายเดือน; 400 "Invalid reply token": ใช้ไปแล้วหรือเกินเวลา
        log("error", "LINE API error", { status: err.status, body: err.body, eventId: event.webhookEventId })
      } else {
        log("error", "event failed", { error: String(err), eventId: event.webhookEventId })
      }
    })
  }
})

#เช็กลิสต์ก่อนเปิดใช้งานจริง

  • request ที่ x-line-signature ไม่ถูกต้องถูกปฏิเสธ
  • สติกเกอร์ รูป และตำแหน่งได้รับคำตอบที่เหมาะสม
  • การส่งต่อให้พนักงานทำงานได้ และพนักงานส่งแชทคืนให้บอทได้
  • ข้อความทักทายและข้อความตอบกลับอัตโนมัติใน LINE Official Account Manager ไม่ตอบซ้ำกับบอท
  • secret อยู่ใน environment variable ไม่ได้อยู่ใน repository
  • มีการแจ้งเตือนเมื่อ Messaging API ตอบ 4xx หรือ 5xx ต่อเนื่อง

#LineBot.pro: ทางเลือกแบบไม่ต้องเขียนโค้ด

ถ้าคุณแค่อยากมีบอทใช้งาน ไม่ได้อยากเขียนเอง ก็มีทางที่สั้นกว่า LineBot.pro ไม่ใช่ SDK, API หรือบริการโฮสติ้ง แต่เป็นชุดเครื่องมือ AI สำหรับ LINE OA ส่วนที่ทับซ้อนกับบทความนี้คือบอทคีย์เวิร์ดในระบบตอบอัตโนมัติ

  • ตั้งได้ 1-8 intent แต่ละ intent มี 1-15 คีย์เวิร์ด และตอบด้วยการ์ด Rich Message ที่คุณอนุมัติแล้ว
  • การตอบด้วยคีย์เวิร์ดไม่เสียเครดิต ส่วนการจัดกลุ่มคำถามด้วย AI (เลือกเปิดได้) จะตรวจข้อความที่ไม่ตรงคีย์เวิร์ดใด (1 เครดิตต่อข้อความที่ตรวจ) และเลือกได้เฉพาะการ์ดที่คุณอนุมัติไว้ AI ไม่เขียนคำตอบเอง
  • คีย์เวิร์ดส่งต่อพนักงานจะหยุดบอทสำหรับลูกค้าคนนั้น
  • รองรับเฉพาะแชท 1:1 และมีตัวจำลองให้ทดสอบฟรีก่อนเปิดใช้จริง

การเชื่อมต่อทำโดยนำ webhook URL ของ LineBot.pro ไปใส่ใน channel ของคุณ และเนื่องจาก 1 channel มี webhook URL ได้เพียง 1 URL จึงใช้คู่กับเซิร์ฟเวอร์ webhook ที่คุณเขียนเองใน channel เดียวกันไม่ได้

นักพัฒนาใช้เฉพาะเครื่องมือสร้างคอนเทนต์ก็ได้ Rich Message ใช้ 8 เครดิต และ Rich Menu ใช้ 10 เครดิต ดาวน์โหลดรูปและ JSON ไปส่งหรืออัปโหลดด้วยโค้ดของคุณเอง สมัครใหม่รับฟรี 50 เครดิต หลังจากนั้นซื้อเครดิตเป็นแพ็กแบบครั้งเดียว ไม่มีรายเดือน และเครดิตไม่มีวันหมดอายุ (ราคา) ดูรายละเอียดบอทคีย์เวิร์ดได้ที่หน้าแชทบอท LINE

ถ้าต้องการบอทที่เชื่อมกับระบบจองคิว ระบบลูกค้า หรือแอป LIFF จะเป็นงานพัฒนาเฉพาะ ดูได้ที่บริการพัฒนาระบบบน LINE

#คำถามที่พบบ่อย

Q: ยังใช้ new Client() ของ @line/bot-sdk ได้อยู่ไหม? ไม่ได้แล้ว คลาส Client แบบเดิมถูกประกาศเลิกใช้ใน v10.8.0 และถูกลบใน v11.0.0 เมื่อวันที่ 3 เมษายน 2026 ให้ใช้ LineBotClient.fromChannelAccessToken() หรือ messagingApi.MessagingApiClient ซึ่งทุกเมธอดรับ request object เดียว เช่น replyMessage({ replyToken, messages }) และ v11 ต้องใช้ Node.js 22 ขึ้นไป

Q: ข้อความ reply นับรวมในโควตาข้อความของ LINE ไหม? ไม่นับ ข้อความที่ส่งด้วย reply token ไม่ถูกนับ ส่วน push, multicast, broadcast และ narrowcast นับ 1 ข้อความต่อผู้รับ 1 คน แพ็กเกจและราคาต่างกันในแต่ละประเทศ ดูได้ในคู่มือราคา LINE OA

Q: reply token ใช้ได้นานแค่ไหน? ใช้ได้ครั้งเดียว และ LINE ระบุให้ใช้ภายใน 1 นาทีหลังได้รับ webhook หลังจากนั้นไม่รับประกันว่าจะใช้ได้ และเวลานี้อาจเปลี่ยนได้ ถ้างานใช้เวลานาน ให้แสดงแอนิเมชันกำลังโหลด หรือตอบทันทีแล้วส่งผลลัพธ์ภายหลังด้วย push message

Q: ทำไมกด Verify ใน LINE Developers Console แล้วไม่ผ่าน? สาเหตุที่พบบ่อยคือ URL ไม่ใช่ HTTPS ที่ใช้ใบรับรองที่เชื่อถือได้, path ผิด, มีตัวแปลง body ทำงานก่อนตรวจลายเซ็น, channel secret เป็นของ channel อื่นหรือถูกออกใหม่แล้ว หรือเซิร์ฟเวอร์ไม่ตอบ 200 เมื่อได้รับ event ว่าง

Q: LINE Bot จำเป็นต้องใช้ AI ไหม? ไม่จำเป็น บอทดี ๆ จำนวนมากใช้แค่ปุ่ม Rich Menu และการตอบตามคีย์เวิร์ด ใช้ AI เมื่อต้องตอบคำถามที่พิมพ์อิสระ จำกัดให้ตอบจากข้อมูลของร้าน และมีพนักงานรับช่วงต่อเสมอ

Q: บอทใช้ในแชทกลุ่มได้ไหม? ได้ เมื่อเปิด Allow bot to join group chats ในแท็บ Messaging API (ค่าเริ่มต้นปิดไว้) event จะมี groupId คำตอบจะส่งถึงทั้งกลุ่ม และใช้แอนิเมชันกำลังโหลดในกลุ่มไม่ได้ บทความนี้ครอบคลุมเฉพาะแชท 1:1


ขั้นตอนต่อไป:

เกี่ยวกับ LineBot.pro

LineBot.pro คือเครื่องมือ AI สำหรับ LINE Official Account ที่ช่วยสร้าง Rich Menu, Rich Message และแบนเนอร์ ส่งแคมเปญบรอดแคสต์ และตอบกลับอัตโนมัติด้วยคำสำคัญ ทีมงานของเราเขียนคู่มือเหล่านี้จากประสบการณ์จริงกับ LINE Messaging API

ติดต่อเรา
บล็อก

บทความที่เกี่ยวข้อง

ตัวอย่าง @line/bot-sdk Express Middleware (v11, TypeScript)
บทเรียน

ตัวอย่าง @line/bot-sdk Express Middleware (v11, TypeScript)

โค้ด webhook ด้วย Express + TypeScript สำหรับ @line/bot-sdk v11 ที่ตรวจ type แล้ว: วาง middleware() ตรงไหน, validateSignature ทำงานอย่างไรใน Node, Python และ PHP, replyMessage พร้อม Flex Message, sentMessages กับ quote token และ error ที่เจอจริง

อ่านเพิ่มเติม
เชื่อมต่อ LINE API สำหรับนักพัฒนา: Messaging API, Login, LIFF
บทเรียน

เชื่อมต่อ LINE API สำหรับนักพัฒนา: Messaging API, Login, LIFF

ภาพรวม LINE API ปี 2026 สำหรับนักพัฒนา และวิธีเรียกใช้โดยตรง: แต่ละ API ทำอะไรได้ การตั้งค่า channel การ reply และ push ด้วย fetch การตรวจ signature ของ webhook, LINE Login แบบ PKCE, ID token จาก LIFF, rate limit และการจัดการ error

อ่านเพิ่มเติม
Rich Message LINE คืออะไร? การ์ดเมสเสจ & Flex Message
คู่มือ

Rich Message LINE คืออะไร? การ์ดเมสเสจ & Flex Message

สรุปครบ Rich Message, การ์ดเมสเสจ, ริชวิดีโอเมสเสจ และ Flex Message ต่างกันอย่างไร ใช้รูปขนาดเท่าไหร่ ทำใน LINE OA Manager ยังไง และส่งแล้วเสียโควตากี่ข้อความ

อ่านเพิ่มเติม
LineBot.pro

พร้อมให้ LINE OA ของคุณทำงานอัตโนมัติหรือยัง?

เริ่มสร้างคอนเทนต์และระบบตอบกลับอัตโนมัติบน LINE กับ LineBot.pro วันนี้