如果 Telegram 的 webhook 像是「收到包裹先看寄件人」,那 LINE 的 webhook 更像是「包裹上的封條要先驗真,再決定要不要拆」。
這不是比喻而已。
LINE 的 webhook 驗證真的很在意 raw body,因為 signature 是對 body 做 HMAC。
如果 body 被 middleware 先改寫過,驗證就可能直接失真。
所以第 24 天我要看的不是「LINE 收到事件後會做什麼」,而是:
LINE plugin 怎麼在事件、驗證、重放、權限這幾件事之間活下來?
這一篇會把 LINE webhook 的防線和事件處理串起來。
LINE 的事件處理我會切成五段:
handleLineWebhookEvents 接收事件陣列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 對群組的處理不是單純「有人傳來就收」。
它會考慮:
然後才把事件變成 message context,再送去 processMessage(...)。
Telegram 也有 secret token,但 LINE 的驗證是 body-dependent 的。
意思是:
所以 LINE 的 webhook 入口特別強調 raw body。
真實世界裡 webhook 不會永遠只來一次。
重送、重試、延遲、網路抖動,這些都會發生。
如果你不做 replay cache,系統很容易把同一件事做兩次。
LINE 這裡的做法是:
這是比較成熟的事件處理方式。
LINE 的群組世界跟 DM 世界不太一樣。
你會看到它一直在分:
dmPolicy
groupPolicy
allowFrom
groupAllowFrom
這些不是複雜化而已,而是因為 LINE 的群組真的需要更細的安全邏輯。
buildLineMessageContext 是事件轉工作流的關卡Webhook event 本身只是平台資料。
OpenClaw 需要的是:
所以 LINE plugin 先處理驗證和 policy,最後才把事件翻成 OpenClaw 能直接處理的 inbound context。
但這些代價都很合理。
因為 LINE 入口如果不嚴,核心再強也沒用。
第 25 天我會把 Telegram 和 LINE 放在同一張桌上比。
你會看到它們明明是兩種入口,但共用的核心其實比你想的還多。