mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 01:13:29 +00:00
refactor(line): extract node webhook handler + shared verification
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { readLineWebhookRequestBody } from "./monitor.js";
|
import { readLineWebhookRequestBody } from "./webhook-node.js";
|
||||||
|
|
||||||
function createMockRequest(chunks: string[]): IncomingMessage {
|
function createMockRequest(chunks: string[]): IncomingMessage {
|
||||||
const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void };
|
const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void };
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { WebhookRequestBody } from "@line/bot-sdk";
|
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
|
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
|
||||||
@@ -7,11 +6,6 @@ import { chunkMarkdownText } from "../auto-reply/chunk.js";
|
|||||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||||
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||||
import { danger, logVerbose } from "../globals.js";
|
import { danger, logVerbose } from "../globals.js";
|
||||||
import {
|
|
||||||
isRequestBodyLimitError,
|
|
||||||
readRequestBodyWithLimit,
|
|
||||||
requestBodyErrorToText,
|
|
||||||
} from "../infra/http-body.js";
|
|
||||||
import { normalizePluginHttpPath } from "../plugins/http-path.js";
|
import { normalizePluginHttpPath } from "../plugins/http-path.js";
|
||||||
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
|
import { registerPluginHttpRoute } from "../plugins/http-registry.js";
|
||||||
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
|
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
|
||||||
@@ -31,8 +25,8 @@ import {
|
|||||||
createImageMessage,
|
createImageMessage,
|
||||||
createLocationMessage,
|
createLocationMessage,
|
||||||
} from "./send.js";
|
} from "./send.js";
|
||||||
import { validateLineSignature } from "./signature.js";
|
|
||||||
import { buildTemplateMessageFromPayload } from "./template-messages.js";
|
import { buildTemplateMessageFromPayload } from "./template-messages.js";
|
||||||
|
import { createLineNodeWebhookHandler } from "./webhook-node.js";
|
||||||
|
|
||||||
export interface MonitorLineProviderOptions {
|
export interface MonitorLineProviderOptions {
|
||||||
channelAccessToken: string;
|
channelAccessToken: string;
|
||||||
@@ -51,9 +45,6 @@ export interface LineProviderMonitor {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
|
||||||
const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
// Track runtime state in memory (simplified version)
|
// Track runtime state in memory (simplified version)
|
||||||
const runtimeState = new Map<
|
const runtimeState = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -93,16 +84,6 @@ export function getLineRuntimeState(accountId: string) {
|
|||||||
return runtimeState.get(`line:${accountId}`);
|
return runtimeState.get(`line:${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readLineWebhookRequestBody(
|
|
||||||
req: IncomingMessage,
|
|
||||||
maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES,
|
|
||||||
): Promise<string> {
|
|
||||||
return await readRequestBodyWithLimit(req, {
|
|
||||||
maxBytes,
|
|
||||||
timeoutMs: LINE_WEBHOOK_BODY_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startLineLoadingKeepalive(params: {
|
function startLineLoadingKeepalive(params: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -300,97 +281,7 @@ export async function monitorLineProvider(
|
|||||||
pluginId: "line",
|
pluginId: "line",
|
||||||
accountId: resolvedAccountId,
|
accountId: resolvedAccountId,
|
||||||
log: (msg) => logVerbose(msg),
|
log: (msg) => logVerbose(msg),
|
||||||
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
handler: createLineNodeWebhookHandler({ channelSecret, bot, runtime }),
|
||||||
// Handle GET requests for webhook verification
|
|
||||||
if (req.method === "GET") {
|
|
||||||
res.statusCode = 200;
|
|
||||||
res.setHeader("Content-Type", "text/plain");
|
|
||||||
res.end("OK");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only accept POST requests
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
res.statusCode = 405;
|
|
||||||
res.setHeader("Allow", "GET, POST");
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rawBody = await readLineWebhookRequestBody(req, LINE_WEBHOOK_MAX_BODY_BYTES);
|
|
||||||
const signature = req.headers["x-line-signature"];
|
|
||||||
|
|
||||||
// LINE webhook verification sends POST {"events":[]} without a
|
|
||||||
// signature header. Return 200 so the LINE Developers Console
|
|
||||||
// "Verify" button succeeds.
|
|
||||||
if (!signature || typeof signature !== "string") {
|
|
||||||
try {
|
|
||||||
const verifyBody = JSON.parse(rawBody) as WebhookRequestBody;
|
|
||||||
if (Array.isArray(verifyBody.events) && verifyBody.events.length === 0) {
|
|
||||||
logVerbose(
|
|
||||||
"line: webhook verification request (empty events, no signature) - 200 OK",
|
|
||||||
);
|
|
||||||
res.statusCode = 200;
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ status: "ok" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not valid JSON — fall through to the error below.
|
|
||||||
}
|
|
||||||
logVerbose("line: webhook missing X-Line-Signature header");
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateLineSignature(rawBody, signature, channelSecret)) {
|
|
||||||
logVerbose("line: webhook signature validation failed");
|
|
||||||
res.statusCode = 401;
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ error: "Invalid signature" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and process the webhook body
|
|
||||||
const body = JSON.parse(rawBody) as WebhookRequestBody;
|
|
||||||
|
|
||||||
// Respond immediately with 200 to avoid LINE timeout
|
|
||||||
res.statusCode = 200;
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ status: "ok" }));
|
|
||||||
|
|
||||||
// Process events asynchronously
|
|
||||||
if (body.events && body.events.length > 0) {
|
|
||||||
logVerbose(`line: received ${body.events.length} webhook events`);
|
|
||||||
await bot.handleWebhook(body).catch((err) => {
|
|
||||||
runtime.error?.(danger(`line webhook handler failed: ${String(err)}`));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
|
||||||
res.statusCode = 413;
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ error: "Payload too large" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
|
||||||
res.statusCode = 408;
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
runtime.error?.(danger(`line webhook error: ${String(err)}`));
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
|
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
|
||||||
|
|||||||
154
src/line/webhook-node.test.ts
Normal file
154
src/line/webhook-node.test.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createLineNodeWebhookHandler } from "./webhook-node.js";
|
||||||
|
|
||||||
|
const sign = (body: string, secret: string) =>
|
||||||
|
crypto.createHmac("SHA256", secret).update(body).digest("base64");
|
||||||
|
|
||||||
|
function createRes() {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const res = {
|
||||||
|
statusCode: 0,
|
||||||
|
headersSent: false,
|
||||||
|
setHeader: (k: string, v: string) => {
|
||||||
|
headers[k.toLowerCase()] = v;
|
||||||
|
},
|
||||||
|
end: vi.fn((data?: unknown) => {
|
||||||
|
res.headersSent = true;
|
||||||
|
// Keep payload available for assertions
|
||||||
|
(res as { body?: unknown }).body = data;
|
||||||
|
}),
|
||||||
|
} as unknown as ServerResponse & { body?: unknown };
|
||||||
|
return { res, headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createLineNodeWebhookHandler", () => {
|
||||||
|
it("returns 200 for GET", async () => {
|
||||||
|
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||||
|
const runtime = { error: vi.fn() };
|
||||||
|
const handler = createLineNodeWebhookHandler({
|
||||||
|
channelSecret: "secret",
|
||||||
|
bot,
|
||||||
|
runtime,
|
||||||
|
readBody: async () => "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { res } = createRes();
|
||||||
|
await handler({ method: "GET", headers: {} } as unknown as IncomingMessage, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body).toBe("OK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 for verification request (empty events, no signature)", async () => {
|
||||||
|
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||||
|
const runtime = { error: vi.fn() };
|
||||||
|
const rawBody = JSON.stringify({ events: [] });
|
||||||
|
const handler = createLineNodeWebhookHandler({
|
||||||
|
channelSecret: "secret",
|
||||||
|
bot,
|
||||||
|
runtime,
|
||||||
|
readBody: async () => rawBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { res, headers } = createRes();
|
||||||
|
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(headers["content-type"]).toBe("application/json");
|
||||||
|
expect(res.body).toBe(JSON.stringify({ status: "ok" }));
|
||||||
|
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing signature when events are non-empty", async () => {
|
||||||
|
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||||
|
const runtime = { error: vi.fn() };
|
||||||
|
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||||
|
const handler = createLineNodeWebhookHandler({
|
||||||
|
channelSecret: "secret",
|
||||||
|
bot,
|
||||||
|
runtime,
|
||||||
|
readBody: async () => rawBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { res } = createRes();
|
||||||
|
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid signature", async () => {
|
||||||
|
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||||
|
const runtime = { error: vi.fn() };
|
||||||
|
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||||
|
const handler = createLineNodeWebhookHandler({
|
||||||
|
channelSecret: "secret",
|
||||||
|
bot,
|
||||||
|
runtime,
|
||||||
|
readBody: async () => rawBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { res } = createRes();
|
||||||
|
await handler(
|
||||||
|
{ method: "POST", headers: { "x-line-signature": "bad" } } as unknown as IncomingMessage,
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid signature and dispatches events", async () => {
|
||||||
|
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||||
|
const runtime = { error: vi.fn() };
|
||||||
|
const secret = "secret";
|
||||||
|
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
||||||
|
const handler = createLineNodeWebhookHandler({
|
||||||
|
channelSecret: secret,
|
||||||
|
bot,
|
||||||
|
runtime,
|
||||||
|
readBody: async () => rawBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { res } = createRes();
|
||||||
|
await handler(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-line-signature": sign(rawBody, secret) },
|
||||||
|
} as unknown as IncomingMessage,
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(bot.handleWebhook).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ events: expect.any(Array) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid JSON payload even when signature is valid", async () => {
|
||||||
|
const bot = { handleWebhook: vi.fn(async () => {}) };
|
||||||
|
const runtime = { error: vi.fn() };
|
||||||
|
const secret = "secret";
|
||||||
|
const rawBody = "not json";
|
||||||
|
const handler = createLineNodeWebhookHandler({
|
||||||
|
channelSecret: secret,
|
||||||
|
bot,
|
||||||
|
runtime,
|
||||||
|
readBody: async () => rawBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { res } = createRes();
|
||||||
|
await handler(
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-line-signature": sign(rawBody, secret) },
|
||||||
|
} as unknown as IncomingMessage,
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
129
src/line/webhook-node.ts
Normal file
129
src/line/webhook-node.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { danger, logVerbose } from "../globals.js";
|
||||||
|
import {
|
||||||
|
isRequestBodyLimitError,
|
||||||
|
readRequestBodyWithLimit,
|
||||||
|
requestBodyErrorToText,
|
||||||
|
} from "../infra/http-body.js";
|
||||||
|
import { validateLineSignature } from "./signature.js";
|
||||||
|
import { isLineWebhookVerificationRequest, parseLineWebhookBody } from "./webhook-utils.js";
|
||||||
|
|
||||||
|
const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
||||||
|
const LINE_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export async function readLineWebhookRequestBody(
|
||||||
|
req: IncomingMessage,
|
||||||
|
maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES,
|
||||||
|
): Promise<string> {
|
||||||
|
return await readRequestBodyWithLimit(req, {
|
||||||
|
maxBytes,
|
||||||
|
timeoutMs: LINE_WEBHOOK_BODY_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadBodyFn = (req: IncomingMessage, maxBytes: number) => Promise<string>;
|
||||||
|
|
||||||
|
export function createLineNodeWebhookHandler(params: {
|
||||||
|
channelSecret: string;
|
||||||
|
bot: { handleWebhook: (body: WebhookRequestBody) => Promise<void> };
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
readBody?: ReadBodyFn;
|
||||||
|
maxBodyBytes?: number;
|
||||||
|
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||||
|
const maxBodyBytes = params.maxBodyBytes ?? LINE_WEBHOOK_MAX_BODY_BYTES;
|
||||||
|
const readBody = params.readBody ?? readLineWebhookRequestBody;
|
||||||
|
|
||||||
|
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
// Handle GET requests for webhook verification
|
||||||
|
if (req.method === "GET") {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "text/plain");
|
||||||
|
res.end("OK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept POST requests
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.setHeader("Allow", "GET, POST");
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawBody = await readBody(req, maxBodyBytes);
|
||||||
|
const signature = req.headers["x-line-signature"];
|
||||||
|
|
||||||
|
// Parse once; we may need it for verification requests and for event processing.
|
||||||
|
const body = parseLineWebhookBody(rawBody);
|
||||||
|
|
||||||
|
// LINE webhook verification sends POST {"events":[]} without a
|
||||||
|
// signature header. Return 200 so the LINE Developers Console
|
||||||
|
// "Verify" button succeeds.
|
||||||
|
if (!signature || typeof signature !== "string") {
|
||||||
|
if (isLineWebhookVerificationRequest(body)) {
|
||||||
|
logVerbose("line: webhook verification request (empty events, no signature) - 200 OK");
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ status: "ok" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logVerbose("line: webhook missing X-Line-Signature header");
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateLineSignature(rawBody, signature, params.channelSecret)) {
|
||||||
|
logVerbose("line: webhook signature validation failed");
|
||||||
|
res.statusCode = 401;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Invalid signature" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Invalid webhook payload" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond immediately with 200 to avoid LINE timeout
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ status: "ok" }));
|
||||||
|
|
||||||
|
// Process events asynchronously
|
||||||
|
if (body.events && body.events.length > 0) {
|
||||||
|
logVerbose(`line: received ${body.events.length} webhook events`);
|
||||||
|
await params.bot.handleWebhook(body).catch((err) => {
|
||||||
|
params.runtime.error?.(danger(`line webhook handler failed: ${String(err)}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
||||||
|
res.statusCode = 413;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Payload too large" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
||||||
|
res.statusCode = 408;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
params.runtime.error?.(danger(`line webhook error: ${String(err)}`));
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ error: "Internal server error" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src/line/webhook-utils.ts
Normal file
15
src/line/webhook-utils.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { WebhookRequestBody } from "@line/bot-sdk";
|
||||||
|
|
||||||
|
export function parseLineWebhookBody(rawBody: string): WebhookRequestBody | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody) as WebhookRequestBody;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLineWebhookVerificationRequest(
|
||||||
|
body: WebhookRequestBody | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return !!body && Array.isArray(body.events) && body.events.length === 0;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Request, Response, NextFunction } from "express";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { logVerbose, danger } from "../globals.js";
|
import { logVerbose, danger } from "../globals.js";
|
||||||
import { validateLineSignature } from "./signature.js";
|
import { validateLineSignature } from "./signature.js";
|
||||||
|
import { isLineWebhookVerificationRequest, parseLineWebhookBody } from "./webhook-utils.js";
|
||||||
|
|
||||||
export interface LineWebhookOptions {
|
export interface LineWebhookOptions {
|
||||||
channelSecret: string;
|
channelSecret: string;
|
||||||
@@ -20,15 +21,14 @@ function readRawBody(req: Request): string | null {
|
|||||||
return Buffer.isBuffer(rawBody) ? rawBody.toString("utf-8") : rawBody;
|
return Buffer.isBuffer(rawBody) ? rawBody.toString("utf-8") : rawBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseWebhookBody(req: Request, rawBody: string): WebhookRequestBody | null {
|
function parseWebhookBody(req: Request, rawBody?: string | null): WebhookRequestBody | null {
|
||||||
if (req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body)) {
|
if (req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body)) {
|
||||||
return req.body as WebhookRequestBody;
|
return req.body as WebhookRequestBody;
|
||||||
}
|
}
|
||||||
try {
|
if (!rawBody) {
|
||||||
return JSON.parse(rawBody) as WebhookRequestBody;
|
|
||||||
} catch {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return parseLineWebhookBody(rawBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLineWebhookMiddleware(
|
export function createLineWebhookMiddleware(
|
||||||
@@ -40,18 +40,16 @@ export function createLineWebhookMiddleware(
|
|||||||
try {
|
try {
|
||||||
const signature = req.headers["x-line-signature"];
|
const signature = req.headers["x-line-signature"];
|
||||||
const rawBody = readRawBody(req);
|
const rawBody = readRawBody(req);
|
||||||
|
const body = parseWebhookBody(req, rawBody);
|
||||||
|
|
||||||
// LINE webhook verification sends POST {"events":[]} without a
|
// LINE webhook verification sends POST {"events":[]} without a
|
||||||
// signature header. Return 200 immediately so the LINE Developers
|
// signature header. Return 200 immediately so the LINE Developers
|
||||||
// Console "Verify" button succeeds.
|
// Console "Verify" button succeeds.
|
||||||
if (!signature || typeof signature !== "string") {
|
if (!signature || typeof signature !== "string") {
|
||||||
if (rawBody) {
|
if (isLineWebhookVerificationRequest(body)) {
|
||||||
const body = parseWebhookBody(req, rawBody);
|
logVerbose("line: webhook verification request (empty events, no signature) - 200 OK");
|
||||||
if (body && Array.isArray(body.events) && body.events.length === 0) {
|
res.status(200).json({ status: "ok" });
|
||||||
logVerbose("line: webhook verification request (empty events, no signature) - 200 OK");
|
return;
|
||||||
res.status(200).json({ status: "ok" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
res.status(400).json({ error: "Missing X-Line-Signature header" });
|
res.status(400).json({ error: "Missing X-Line-Signature header" });
|
||||||
return;
|
return;
|
||||||
@@ -68,7 +66,6 @@ export function createLineWebhookMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = parseWebhookBody(req, rawBody);
|
|
||||||
if (!body) {
|
if (!body) {
|
||||||
res.status(400).json({ error: "Invalid webhook payload" });
|
res.status(400).json({ error: "Invalid webhook payload" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user