iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

開場故事

如果 Telegram 的 webhook 像是「收到包裹先看寄件人」,那 LINE 的 webhook 更像是「包裹上的封條要先驗真,再決定要不要拆」。

這不是比喻而已。

LINE 的 webhook 驗證真的很在意 raw body,因為 signature 是對 body 做 HMAC。
如果 body 被 middleware 先改寫過,驗證就可能直接失真。

所以第 24 天我要看的不是「LINE 收到事件後會做什麼」,而是:

LINE plugin 怎麼在事件、驗證、重放、權限這幾件事之間活下來?

這一篇會把 LINE webhook 的防線和事件處理串起來。

今天要解的問題

  • 為什麼 LINE 必須驗 raw body?
  • signature 驗證怎麼做得比較穩?
  • webhook 進來後怎麼避免 duplicate event?
  • LINE 怎麼分辨 DM、group、room,還有誰可以講話?
  • 為什麼 LINE 的事件處理比看起來更像一條防守線?

架構總覽

LINE 的事件處理我會切成五段:

  1. webhook middleware 先驗簽章
  2. handleLineWebhookEvents 接收事件陣列
  3. replay cache 避免重複處理
  4. access control 決定要不要放行
  5. buildLineMessageContext 把事件變成 OpenClaw 可以吃的上下文

這條線的核心不是「解析 JSON」,而是:

先證明這包事件是真的,再證明這個人可以講話,最後才把它丟進核心流程。

原始碼節錄

先看簽章驗證。

📄 原始碼:extensions/line/src/signature.ts:5-10

export function validateLineSignature(
  body: string,
  signature: string,
  channelSecret: string,
): boolean {
  const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
  const hashBuffer = Buffer.from(hash);
  const signatureBuffer = Buffer.from(signature);

  // Pad to equal length before constant-time comparison to prevent
  // leaking length information via early-return timing.
  const maxLen = Math.max(hashBuffer.length, signatureBuffer.length);
  const paddedHash = Buffer.alloc(maxLen);
  const paddedSig = Buffer.alloc(maxLen);
  hashBuffer.copy(paddedHash);
  signatureBuffer.copy(paddedSig);

  // Call timingSafeEqual unconditionally to ensure constant-time execution
  // regardless of length mismatch (avoids && short-circuit timing leak).
  const timingResult = crypto.timingSafeEqual(paddedHash, paddedSig);
  return hashBuffer.length === signatureBuffer.length && timingResult;
}

這段很扎實。

它不是簡單地比對字串,而是先做 HMAC,再用 constant-time compare 避免 timing leak。

再看 webhook middleware 怎麼用它。

📄 原始碼:extensions/line/src/webhook.ts:48-68

const signature = req.headers["x-line-signature"];

if (!signature || typeof signature !== "string") {
  res.status(400).json({ error: "Missing X-Line-Signature header" });
  return;
}

const rawBody = readRawBody(req);

if (!rawBody) {
  res.status(400).json({ error: "Missing raw request body for signature verification" });
  return;
}
if (Buffer.byteLength(rawBody, "utf-8") > LINE_WEBHOOK_MAX_RAW_BODY_BYTES) {
  res.status(413).json({ error: "Payload too large" });
  return;
}

if (!validateLineSignature(rawBody, signature, channelSecret)) {
  logVerbose("line: webhook signature validation failed");
  res.status(401).json({ error: "Invalid signature" });
  return;
}

這就是 LINE 的第一道防線。

重點是它拿到的是 raw body,不是已經被亂動過的 req.body。
這件事對 signature 驗證來說超重要。

接著看事件怎麼避免重放。

📄 原始碼:extensions/line/src/bot-handlers.ts:88-156

export function createLineWebhookReplayCache(): LineWebhookReplayCache {
  return {
    seenEvents: new Map<string, number>(),
    inFlightEvents: new Map<string, Promise<void>>(),
    lastPruneAtMs: 0,
  };
}

function shouldSkipLineReplayEvent(
  candidate: LineReplayCandidate,
): { skip: true; inFlightResult?: Promise<void> } | { skip: false } {
  const inFlightResult = candidate.cache.inFlightEvents.get(candidate.key);
  if (inFlightResult) {
    logVerbose(`line: skipped in-flight replayed webhook event ${candidate.eventId}`);
    return { skip: true, inFlightResult };
  }
  if (candidate.cache.seenEvents.has(candidate.key)) {
    logVerbose(`line: skipped replayed webhook event ${candidate.eventId}`);
    return { skip: true };
  }
  return { skip: false };
}

這裡其實是在做事件去重。

LINE webhook 有時候會重送事件,或者同一個事件會在短時間內重進來。
如果沒有 replay cache,你可能會看到同一則訊息被處理兩次。

最後看 access control 和 message context。

📄 原始碼:extensions/line/src/bot-handlers.ts:343-358

if (isGroup) {
  if (groupConfig?.enabled === false) {
    logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
    return denied;
  }
  if (typeof groupAllowOverride !== "undefined") {
    if (!senderId) {
      logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
      return denied;
    }
    if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
      logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
      return denied;
    }
  }
  const senderGroupAccess = evaluateMatchedGroupAccessForPolicy({
    groupPolicy,
    requireMatchInput: true,
    hasMatchInput: Boolean(senderId),
    allowlistConfigured: effectiveGroupAllow.entries.length > 0,
    allowlistMatched:
      Boolean(senderId) &&
      isSenderAllowed({
        allow: effectiveGroupAllow,
        senderId,
      }),
  });
  if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
    logVerbose("Blocked line group message (groupPolicy: disabled)");
    return denied;
  }

這段顯示 LINE 對群組的處理不是單純「有人傳來就收」。

它會考慮:

  • group enabled
  • group allow override
  • group policy
  • allowlist
  • sender id 有沒有出現

然後才把事件變成 message context,再送去 processMessage(...)

白話拆解

1. LINE 很在意「原始封包」

Telegram 也有 secret token,但 LINE 的驗證是 body-dependent 的。

意思是:

  • body 一改,signature 就可能對不上
  • middleware 如果先 parse 再 stringify,可能就不是原本那個 body

所以 LINE 的 webhook 入口特別強調 raw body。

2. replay cache 不是多餘,而是 webhook 的現實

真實世界裡 webhook 不會永遠只來一次。

重送、重試、延遲、網路抖動,這些都會發生。
如果你不做 replay cache,系統很容易把同一件事做兩次。

LINE 這裡的做法是:

  • 先看 in-flight
  • 再看 seen
  • 沒問題才進處理

這是比較成熟的事件處理方式。

3. group policy 是 LINE 的另一個重點

LINE 的群組世界跟 DM 世界不太一樣。

你會看到它一直在分:

  • dmPolicy
  • groupPolicy
  • allowFrom
  • groupAllowFrom
  • per-group override

這些不是複雜化而已,而是因為 LINE 的群組真的需要更細的安全邏輯。

4. buildLineMessageContext 是事件轉工作流的關卡

Webhook event 本身只是平台資料。

OpenClaw 需要的是:

  • who
  • where
  • what text
  • what media
  • can command or not
  • should record history or not

所以 LINE plugin 先處理驗證和 policy,最後才把事件翻成 OpenClaw 能直接處理的 inbound context。

設計取捨

  • 好處是安全,raw body 驗證和 constant-time compare 都有做
  • 好處是 duplicate event 比較不會炸掉工作流
  • 好處是 access control 很細,可以針對 DM、group、room 各自調整
  • 好處是 message context 一進 core 就已經是乾淨資料
  • 代價是入口檔和 handler 檔會比較長
  • 代價是 debug 時要看驗證、replay、access、context 四件事,不是單看 webhook route 就夠

但這些代價都很合理。
因為 LINE 入口如果不嚴,核心再強也沒用。

今天的結論

  • LINE webhook 必須驗 raw body,不能偷懶用已變形的 req.body
  • signature 驗證要 constant-time,避免安全問題
  • replay cache 是 webhook 實務上的必要防線
  • DM / group / room 的 access control 是 LINE plugin 的重要責任
  • 事件要先過驗證和 policy,才會變成 OpenClaw 的 inbound context

下一步

第 25 天我會把 Telegram 和 LINE 放在同一張桌上比。
你會看到它們明明是兩種入口,但共用的核心其實比你想的還多。


上一篇
第 23 天:LINE plugin 為什麼不是,Telegram 的複製貼上
系列文
30 天走進 OpenClaw:一個 AI Agent 的誕生、掙扎與進化24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言