import type { WebhookRequestBody } from "@line/bot-sdk"; import type { Request, Response, NextFunction } from "express"; import { logVerbose, danger } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateLineSignature } from "./signature.js"; import { parseLineWebhookBody } from "./webhook-utils.js"; const LINE_WEBHOOK_MAX_RAW_BODY_BYTES = 64 * 1024; export interface LineWebhookOptions { channelSecret: string; onEvents: (body: WebhookRequestBody) => Promise; runtime?: RuntimeEnv; } function readRawBody(req: Request): string | null { const rawBody = (req as { rawBody?: string | Buffer }).rawBody ?? (typeof req.body === "string" || Buffer.isBuffer(req.body) ? req.body : null); if (!rawBody) { return null; } return Buffer.isBuffer(rawBody) ? rawBody.toString("utf-8") : rawBody; } function parseWebhookBody(req: Request, rawBody?: string | null): WebhookRequestBody | null { if (req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body)) { return req.body as WebhookRequestBody; } if (!rawBody) { return null; } return parseLineWebhookBody(rawBody); } export function createLineWebhookMiddleware( options: LineWebhookOptions, ): (req: Request, res: Response, _next: NextFunction) => Promise { const { channelSecret, onEvents, runtime } = options; return async (req: Request, res: Response, _next: NextFunction): Promise => { try { 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; } const body = parseWebhookBody(req, rawBody); if (!body) { res.status(400).json({ error: "Invalid webhook payload" }); return; } if (body.events && body.events.length > 0) { logVerbose(`line: received ${body.events.length} webhook events`); await onEvents(body); } res.status(200).json({ status: "ok" }); } catch (err) { runtime?.error?.(danger(`line webhook error: ${String(err)}`)); if (!res.headersSent) { res.status(500).json({ error: "Internal server error" }); } } }; } export interface StartLineWebhookOptions { channelSecret: string; onEvents: (body: WebhookRequestBody) => Promise; runtime?: RuntimeEnv; path?: string; } export function startLineWebhook(options: StartLineWebhookOptions): { path: string; handler: (req: Request, res: Response, _next: NextFunction) => Promise; } { const channelSecret = typeof options.channelSecret === "string" ? options.channelSecret.trim() : ""; if (!channelSecret) { throw new Error( "LINE webhook mode requires a non-empty channel secret. " + "Set channels.line.channelSecret in your config.", ); } const path = options.path ?? "/line/webhook"; const middleware = createLineWebhookMiddleware({ channelSecret, onEvents: options.onEvents, runtime: options.runtime, }); return { path, handler: middleware }; }