@openclaw/line 2026.5.2-beta.2 → 2026.5.3-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accounts-UXAvdBo_.js +105 -0
- package/dist/api.js +11 -0
- package/dist/basic-cards-7DTG6E-A.js +307 -0
- package/dist/card-command-UlPc6Vuc.js +240 -0
- package/dist/channel-Rls9s60K.js +601 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.runtime-bXoj8_Sl.js +4 -0
- package/dist/contract-api.js +2 -0
- package/dist/index.js +45 -0
- package/dist/markdown-to-line-CCWK6PJh.js +789 -0
- package/dist/monitor-B4_RxHc0.js +1523 -0
- package/dist/monitor.runtime-DyxYlrM9.js +2 -0
- package/dist/outbound-media-BfhBmWq5.js +55 -0
- package/dist/outbound.runtime-B-luYwEB.js +2 -0
- package/dist/probe-D9BPzb2n.js +30 -0
- package/dist/probe.runtime-N91OWW1J.js +2 -0
- package/dist/quick-reply-fallback-HskUUWsn.js +55 -0
- package/dist/reply-payload-transform-CFGTs9ln.js +713 -0
- package/dist/runtime-api.js +277 -0
- package/dist/schedule-cards-BwQChTdM.js +359 -0
- package/dist/secret-contract-api.js +5 -0
- package/dist/setup-api.js +2 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-surface-DCH7CE-y.js +281 -0
- package/package.json +14 -6
- package/api.ts +0 -11
- package/channel-plugin-api.ts +0 -1
- package/contract-api.ts +0 -5
- package/index.ts +0 -54
- package/runtime-api.ts +0 -187
- package/secret-contract-api.ts +0 -4
- package/setup-api.ts +0 -2
- package/setup-entry.ts +0 -9
- package/src/account-helpers.ts +0 -16
- package/src/accounts.test.ts +0 -290
- package/src/accounts.ts +0 -187
- package/src/actions.ts +0 -61
- package/src/auto-reply-delivery.test.ts +0 -248
- package/src/auto-reply-delivery.ts +0 -200
- package/src/bindings.ts +0 -65
- package/src/bot-access.ts +0 -48
- package/src/bot-handlers.test.ts +0 -1089
- package/src/bot-handlers.ts +0 -642
- package/src/bot-message-context.test.ts +0 -405
- package/src/bot-message-context.ts +0 -581
- package/src/bot.ts +0 -70
- package/src/card-command.ts +0 -347
- package/src/channel-access-token.ts +0 -14
- package/src/channel-api.ts +0 -17
- package/src/channel-setup-status.contract.test.ts +0 -70
- package/src/channel-shared.ts +0 -48
- package/src/channel.logout.test.ts +0 -145
- package/src/channel.runtime.ts +0 -3
- package/src/channel.sendPayload.test.ts +0 -514
- package/src/channel.setup.ts +0 -11
- package/src/channel.status.test.ts +0 -63
- package/src/channel.ts +0 -154
- package/src/config-adapter.ts +0 -29
- package/src/config-schema.ts +0 -57
- package/src/download.test.ts +0 -133
- package/src/download.ts +0 -87
- package/src/flex-templates/basic-cards.ts +0 -395
- package/src/flex-templates/common.ts +0 -20
- package/src/flex-templates/media-control-cards.ts +0 -555
- package/src/flex-templates/message.ts +0 -13
- package/src/flex-templates/schedule-cards.ts +0 -467
- package/src/flex-templates/types.ts +0 -22
- package/src/flex-templates.ts +0 -32
- package/src/gateway.ts +0 -129
- package/src/group-keys.test.ts +0 -123
- package/src/group-keys.ts +0 -65
- package/src/group-policy.ts +0 -22
- package/src/markdown-to-line.test.ts +0 -348
- package/src/markdown-to-line.ts +0 -416
- package/src/message-cards.test.ts +0 -204
- package/src/monitor.lifecycle.test.ts +0 -421
- package/src/monitor.runtime.ts +0 -1
- package/src/monitor.ts +0 -506
- package/src/outbound-media.test.ts +0 -189
- package/src/outbound-media.ts +0 -120
- package/src/outbound.runtime.ts +0 -12
- package/src/outbound.ts +0 -356
- package/src/probe.contract.test.ts +0 -9
- package/src/probe.runtime.ts +0 -1
- package/src/probe.ts +0 -34
- package/src/quick-reply-fallback.ts +0 -10
- package/src/reply-chunks.test.ts +0 -179
- package/src/reply-chunks.ts +0 -110
- package/src/reply-payload-transform.test.ts +0 -387
- package/src/reply-payload-transform.ts +0 -317
- package/src/rich-menu.test.ts +0 -310
- package/src/rich-menu.ts +0 -326
- package/src/runtime.ts +0 -32
- package/src/send.test.ts +0 -346
- package/src/send.ts +0 -489
- package/src/setup-core.ts +0 -149
- package/src/setup-runtime-api.ts +0 -9
- package/src/setup-surface.test.ts +0 -474
- package/src/setup-surface.ts +0 -227
- package/src/signature.test.ts +0 -34
- package/src/signature.ts +0 -24
- package/src/status.ts +0 -37
- package/src/template-messages.ts +0 -333
- package/src/types.ts +0 -128
- package/src/webhook-node.test.ts +0 -513
- package/src/webhook-node.ts +0 -131
- package/src/webhook-utils.ts +0 -10
- package/src/webhook.ts +0 -111
- package/tsconfig.json +0 -16
package/src/webhook-node.test.ts
DELETED
|
@@ -1,513 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
-
import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./webhook-node.js";
|
|
6
|
-
import { createLineWebhookMiddleware } from "./webhook.js";
|
|
7
|
-
|
|
8
|
-
const sign = (body: string, secret: string) =>
|
|
9
|
-
crypto.createHmac("SHA256", secret).update(body).digest("base64");
|
|
10
|
-
|
|
11
|
-
function createRes() {
|
|
12
|
-
const headers: Record<string, string> = {};
|
|
13
|
-
const resObj = {
|
|
14
|
-
statusCode: 0,
|
|
15
|
-
headersSent: false,
|
|
16
|
-
setHeader: (k: string, v: string) => {
|
|
17
|
-
headers[k.toLowerCase()] = v;
|
|
18
|
-
},
|
|
19
|
-
end: vi.fn((data?: unknown) => {
|
|
20
|
-
resObj.headersSent = true;
|
|
21
|
-
// Keep payload available for assertions
|
|
22
|
-
resObj.body = data;
|
|
23
|
-
}),
|
|
24
|
-
body: undefined as unknown,
|
|
25
|
-
};
|
|
26
|
-
const res = resObj as unknown as ServerResponse & { body?: unknown };
|
|
27
|
-
return { res, headers };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const SECRET = "secret";
|
|
31
|
-
|
|
32
|
-
function createMiddlewareRes() {
|
|
33
|
-
const res = {
|
|
34
|
-
status: vi.fn(),
|
|
35
|
-
json: vi.fn(),
|
|
36
|
-
headersSent: false,
|
|
37
|
-
} as any;
|
|
38
|
-
res.status.mockReturnValue(res);
|
|
39
|
-
res.json.mockReturnValue(res);
|
|
40
|
-
return res;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function createPostWebhookTestHarness(rawBody: string, secret = "secret") {
|
|
44
|
-
const bot = { handleWebhook: vi.fn(async () => {}) };
|
|
45
|
-
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
46
|
-
const handler = createLineNodeWebhookHandler({
|
|
47
|
-
channelSecret: secret,
|
|
48
|
-
bot,
|
|
49
|
-
runtime,
|
|
50
|
-
readBody: async () => rawBody,
|
|
51
|
-
});
|
|
52
|
-
return { bot, handler, secret };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const runSignedPost = async (params: {
|
|
56
|
-
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
57
|
-
rawBody: string;
|
|
58
|
-
secret: string;
|
|
59
|
-
res: ServerResponse;
|
|
60
|
-
}) =>
|
|
61
|
-
await params.handler(
|
|
62
|
-
{
|
|
63
|
-
method: "POST",
|
|
64
|
-
headers: { "x-line-signature": sign(params.rawBody, params.secret) },
|
|
65
|
-
} as unknown as IncomingMessage,
|
|
66
|
-
params.res,
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
async function invokeWebhook(params: {
|
|
70
|
-
body: unknown;
|
|
71
|
-
headers?: Record<string, string>;
|
|
72
|
-
onEvents?: ReturnType<typeof vi.fn>;
|
|
73
|
-
autoSign?: boolean;
|
|
74
|
-
}) {
|
|
75
|
-
const onEventsMock = params.onEvents ?? vi.fn(async () => {});
|
|
76
|
-
const middleware = createLineWebhookMiddleware({
|
|
77
|
-
channelSecret: SECRET,
|
|
78
|
-
onEvents: onEventsMock as never,
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
const headers = { ...params.headers };
|
|
82
|
-
const autoSign = params.autoSign ?? true;
|
|
83
|
-
if (autoSign && !headers["x-line-signature"]) {
|
|
84
|
-
if (typeof params.body === "string") {
|
|
85
|
-
headers["x-line-signature"] = sign(params.body, SECRET);
|
|
86
|
-
} else if (Buffer.isBuffer(params.body)) {
|
|
87
|
-
headers["x-line-signature"] = sign(params.body.toString("utf-8"), SECRET);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const req = {
|
|
92
|
-
headers,
|
|
93
|
-
body: params.body,
|
|
94
|
-
} as any;
|
|
95
|
-
const res = createMiddlewareRes();
|
|
96
|
-
await middleware(req, res, {} as any);
|
|
97
|
-
return { res, onEvents: onEventsMock };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function expectSignedRawBodyWins(params: { rawBody: string | Buffer; signedUserId: string }) {
|
|
101
|
-
const onEvents = vi.fn(async () => {});
|
|
102
|
-
const reqBody = {
|
|
103
|
-
events: [{ type: "message", source: { userId: "tampered-user" } }],
|
|
104
|
-
};
|
|
105
|
-
const middleware = createLineWebhookMiddleware({
|
|
106
|
-
channelSecret: SECRET,
|
|
107
|
-
onEvents,
|
|
108
|
-
});
|
|
109
|
-
const rawBodyText =
|
|
110
|
-
typeof params.rawBody === "string" ? params.rawBody : params.rawBody.toString("utf-8");
|
|
111
|
-
const req = {
|
|
112
|
-
headers: { "x-line-signature": sign(rawBodyText, SECRET) },
|
|
113
|
-
rawBody: params.rawBody,
|
|
114
|
-
body: reqBody,
|
|
115
|
-
} as any;
|
|
116
|
-
const res = createMiddlewareRes();
|
|
117
|
-
|
|
118
|
-
await middleware(req, res, {} as any);
|
|
119
|
-
|
|
120
|
-
expect(res.status).toHaveBeenCalledWith(200);
|
|
121
|
-
expect(onEvents).toHaveBeenCalledTimes(1);
|
|
122
|
-
const processedBody = (
|
|
123
|
-
onEvents.mock.calls[0] as unknown as [{ events?: Array<{ source?: { userId?: string } }> }]
|
|
124
|
-
)?.[0];
|
|
125
|
-
expect(processedBody?.events?.[0]?.source?.userId).toBe(params.signedUserId);
|
|
126
|
-
expect(processedBody?.events?.[0]?.source?.userId).not.toBe("tampered-user");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
describe("createLineNodeWebhookHandler", () => {
|
|
130
|
-
it("returns 200 for GET", async () => {
|
|
131
|
-
const bot = { handleWebhook: vi.fn(async () => {}) };
|
|
132
|
-
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
133
|
-
const handler = createLineNodeWebhookHandler({
|
|
134
|
-
channelSecret: "secret",
|
|
135
|
-
bot,
|
|
136
|
-
runtime,
|
|
137
|
-
readBody: async () => "",
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const { res } = createRes();
|
|
141
|
-
await handler({ method: "GET", headers: {} } as unknown as IncomingMessage, res);
|
|
142
|
-
|
|
143
|
-
expect(res.statusCode).toBe(200);
|
|
144
|
-
expect(res.body).toBe("OK");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("returns 204 for HEAD", async () => {
|
|
148
|
-
const bot = { handleWebhook: vi.fn(async () => {}) };
|
|
149
|
-
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
150
|
-
const handler = createLineNodeWebhookHandler({
|
|
151
|
-
channelSecret: "secret",
|
|
152
|
-
bot,
|
|
153
|
-
runtime,
|
|
154
|
-
readBody: async () => "",
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
const { res } = createRes();
|
|
158
|
-
await handler({ method: "HEAD", headers: {} } as unknown as IncomingMessage, res);
|
|
159
|
-
|
|
160
|
-
expect(res.statusCode).toBe(204);
|
|
161
|
-
expect(res.body).toBeUndefined();
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("rejects verification-shaped requests without a signature", async () => {
|
|
165
|
-
const rawBody = JSON.stringify({ events: [] });
|
|
166
|
-
const { bot, handler } = createPostWebhookTestHarness(rawBody);
|
|
167
|
-
|
|
168
|
-
const { res, headers } = createRes();
|
|
169
|
-
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
|
170
|
-
|
|
171
|
-
expect(res.statusCode).toBe(400);
|
|
172
|
-
expect(headers["content-type"]).toBe("application/json");
|
|
173
|
-
expect(res.body).toBe(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
|
174
|
-
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it("accepts signed verification-shaped requests without dispatching events", async () => {
|
|
178
|
-
const rawBody = JSON.stringify({ events: [] });
|
|
179
|
-
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
|
|
180
|
-
|
|
181
|
-
const { res, headers } = createRes();
|
|
182
|
-
await runSignedPost({ handler, rawBody, secret, res });
|
|
183
|
-
|
|
184
|
-
expect(res.statusCode).toBe(200);
|
|
185
|
-
expect(headers["content-type"]).toBe("application/json");
|
|
186
|
-
expect(res.body).toBe(JSON.stringify({ status: "ok" }));
|
|
187
|
-
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("returns 405 for non-GET/HEAD/POST methods", async () => {
|
|
191
|
-
const { bot, handler } = createPostWebhookTestHarness(JSON.stringify({ events: [] }));
|
|
192
|
-
|
|
193
|
-
const { res, headers } = createRes();
|
|
194
|
-
await handler({ method: "PUT", headers: {} } as unknown as IncomingMessage, res);
|
|
195
|
-
|
|
196
|
-
expect(res.statusCode).toBe(405);
|
|
197
|
-
expect(headers.allow).toBe("GET, HEAD, POST");
|
|
198
|
-
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("rejects missing signature when events are non-empty", async () => {
|
|
202
|
-
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
|
203
|
-
const { bot, handler } = createPostWebhookTestHarness(rawBody);
|
|
204
|
-
|
|
205
|
-
const { res } = createRes();
|
|
206
|
-
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
|
207
|
-
|
|
208
|
-
expect(res.statusCode).toBe(400);
|
|
209
|
-
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("rejects unsigned POST requests before reading the body", async () => {
|
|
213
|
-
const bot = { handleWebhook: vi.fn(async () => {}) };
|
|
214
|
-
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
215
|
-
const readBody = vi.fn(async () => JSON.stringify({ events: [{ type: "message" }] }));
|
|
216
|
-
const handler = createLineNodeWebhookHandler({
|
|
217
|
-
channelSecret: "secret",
|
|
218
|
-
bot,
|
|
219
|
-
runtime,
|
|
220
|
-
readBody,
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
const { res } = createRes();
|
|
224
|
-
await handler({ method: "POST", headers: {} } as unknown as IncomingMessage, res);
|
|
225
|
-
|
|
226
|
-
expect(res.statusCode).toBe(400);
|
|
227
|
-
expect(readBody).not.toHaveBeenCalled();
|
|
228
|
-
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("uses strict pre-auth limits for signed POST requests", async () => {
|
|
232
|
-
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
|
233
|
-
const bot = { handleWebhook: vi.fn(async () => {}) };
|
|
234
|
-
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
235
|
-
const readBody = vi.fn(async (_req: IncomingMessage, maxBytes: number, timeoutMs?: number) => {
|
|
236
|
-
expect(maxBytes).toBe(64 * 1024);
|
|
237
|
-
expect(timeoutMs).toBe(5_000);
|
|
238
|
-
return rawBody;
|
|
239
|
-
});
|
|
240
|
-
const handler = createLineNodeWebhookHandler({
|
|
241
|
-
channelSecret: "secret",
|
|
242
|
-
bot,
|
|
243
|
-
runtime,
|
|
244
|
-
readBody,
|
|
245
|
-
maxBodyBytes: 1024 * 1024,
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
const { res } = createRes();
|
|
249
|
-
await runSignedPost({ handler, rawBody, secret: "secret", res });
|
|
250
|
-
|
|
251
|
-
expect(res.statusCode).toBe(200);
|
|
252
|
-
expect(readBody).toHaveBeenCalledTimes(1);
|
|
253
|
-
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it("rejects invalid signature", async () => {
|
|
257
|
-
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
|
258
|
-
const { bot, handler } = createPostWebhookTestHarness(rawBody);
|
|
259
|
-
|
|
260
|
-
const { res } = createRes();
|
|
261
|
-
await handler(
|
|
262
|
-
{ method: "POST", headers: { "x-line-signature": "bad" } } as unknown as IncomingMessage,
|
|
263
|
-
res,
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
expect(res.statusCode).toBe(401);
|
|
267
|
-
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("accepts valid signature and dispatches events", async () => {
|
|
271
|
-
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
|
272
|
-
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
|
|
273
|
-
|
|
274
|
-
const { res } = createRes();
|
|
275
|
-
await runSignedPost({ handler, rawBody, secret, res });
|
|
276
|
-
|
|
277
|
-
expect(res.statusCode).toBe(200);
|
|
278
|
-
expect(bot.handleWebhook).toHaveBeenCalledWith(
|
|
279
|
-
expect.objectContaining({ events: expect.any(Array) }),
|
|
280
|
-
);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it("releases authenticated requests before event processing completes", async () => {
|
|
284
|
-
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
|
285
|
-
let releaseAuthenticated!: () => void;
|
|
286
|
-
const bot = {
|
|
287
|
-
handleWebhook: vi.fn(
|
|
288
|
-
async () =>
|
|
289
|
-
await new Promise<void>((resolve) => {
|
|
290
|
-
releaseAuthenticated = resolve;
|
|
291
|
-
}),
|
|
292
|
-
),
|
|
293
|
-
};
|
|
294
|
-
const onRequestAuthenticated = vi.fn();
|
|
295
|
-
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
296
|
-
const handler = createLineNodeWebhookHandler({
|
|
297
|
-
channelSecret: SECRET,
|
|
298
|
-
bot,
|
|
299
|
-
runtime,
|
|
300
|
-
readBody: async () => rawBody,
|
|
301
|
-
onRequestAuthenticated,
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
const { res } = createRes();
|
|
305
|
-
const request = runSignedPost({ handler, rawBody, secret: SECRET, res });
|
|
306
|
-
|
|
307
|
-
await vi.waitFor(() => {
|
|
308
|
-
expect(onRequestAuthenticated).toHaveBeenCalledTimes(1);
|
|
309
|
-
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
expect(res.headersSent).toBe(false);
|
|
313
|
-
releaseAuthenticated();
|
|
314
|
-
await request;
|
|
315
|
-
|
|
316
|
-
expect(res.statusCode).toBe(200);
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
|
|
320
|
-
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
|
321
|
-
const { secret } = createPostWebhookTestHarness(rawBody);
|
|
322
|
-
const failingBot = {
|
|
323
|
-
handleWebhook: vi.fn(async () => {
|
|
324
|
-
throw new Error("transient failure");
|
|
325
|
-
}),
|
|
326
|
-
};
|
|
327
|
-
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
328
|
-
const failingHandler = createLineNodeWebhookHandler({
|
|
329
|
-
channelSecret: secret,
|
|
330
|
-
bot: failingBot,
|
|
331
|
-
runtime,
|
|
332
|
-
readBody: async () => rawBody,
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
const { res } = createRes();
|
|
336
|
-
await runSignedPost({ handler: failingHandler, rawBody, secret, res });
|
|
337
|
-
|
|
338
|
-
expect(res.statusCode).toBe(500);
|
|
339
|
-
expect(res.body).toBe(JSON.stringify({ error: "Internal server error" }));
|
|
340
|
-
expect(failingBot.handleWebhook).toHaveBeenCalledTimes(1);
|
|
341
|
-
expect(runtime.error).toHaveBeenCalledTimes(1);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it("returns 400 for invalid JSON payload even when signature is valid", async () => {
|
|
345
|
-
const rawBody = "not json";
|
|
346
|
-
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);
|
|
347
|
-
|
|
348
|
-
const { res } = createRes();
|
|
349
|
-
await runSignedPost({ handler, rawBody, secret, res });
|
|
350
|
-
|
|
351
|
-
expect(res.statusCode).toBe(400);
|
|
352
|
-
expect(bot.handleWebhook).not.toHaveBeenCalled();
|
|
353
|
-
});
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
describe("readLineWebhookRequestBody", () => {
|
|
357
|
-
it("reads body within limit", async () => {
|
|
358
|
-
const req = createMockIncomingRequest(['{"events":[{"type":"message"}]}']);
|
|
359
|
-
const body = await readLineWebhookRequestBody(req, 1024);
|
|
360
|
-
expect(body).toContain('"events"');
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
it("rejects oversized body", async () => {
|
|
364
|
-
const req = createMockIncomingRequest(["x".repeat(2048)]);
|
|
365
|
-
await expect(readLineWebhookRequestBody(req, 128)).rejects.toThrow("PayloadTooLarge");
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
describe("createLineWebhookMiddleware", () => {
|
|
370
|
-
it.each([
|
|
371
|
-
["raw string body", JSON.stringify({ events: [{ type: "message" }] })],
|
|
372
|
-
["raw buffer body", Buffer.from(JSON.stringify({ events: [{ type: "follow" }] }), "utf-8")],
|
|
373
|
-
])("parses JSON from %s", async (_label, body) => {
|
|
374
|
-
const { res, onEvents } = await invokeWebhook({ body });
|
|
375
|
-
expect(res.status).toHaveBeenCalledWith(200);
|
|
376
|
-
expect(onEvents).toHaveBeenCalledWith(expect.objectContaining({ events: expect.any(Array) }));
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
it("rejects invalid JSON payloads", async () => {
|
|
380
|
-
const { res, onEvents } = await invokeWebhook({ body: "not json" });
|
|
381
|
-
expect(res.status).toHaveBeenCalledWith(400);
|
|
382
|
-
expect(onEvents).not.toHaveBeenCalled();
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it("rejects webhooks with invalid signatures", async () => {
|
|
386
|
-
const { res, onEvents } = await invokeWebhook({
|
|
387
|
-
body: JSON.stringify({ events: [{ type: "message" }] }),
|
|
388
|
-
headers: { "x-line-signature": "invalid-signature" },
|
|
389
|
-
});
|
|
390
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
391
|
-
expect(onEvents).not.toHaveBeenCalled();
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
it("rejects verification-shaped requests without a signature", async () => {
|
|
395
|
-
const { res, onEvents } = await invokeWebhook({
|
|
396
|
-
body: JSON.stringify({ events: [] }),
|
|
397
|
-
headers: {},
|
|
398
|
-
autoSign: false,
|
|
399
|
-
});
|
|
400
|
-
expect(res.status).toHaveBeenCalledWith(400);
|
|
401
|
-
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
|
|
402
|
-
expect(onEvents).not.toHaveBeenCalled();
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
it("accepts signed verification-shaped requests without dispatching events", async () => {
|
|
406
|
-
const { res, onEvents } = await invokeWebhook({
|
|
407
|
-
body: JSON.stringify({ events: [] }),
|
|
408
|
-
});
|
|
409
|
-
expect(res.status).toHaveBeenCalledWith(200);
|
|
410
|
-
expect(res.json).toHaveBeenCalledWith({ status: "ok" });
|
|
411
|
-
expect(onEvents).not.toHaveBeenCalled();
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it("rejects oversized signed payloads before JSON parsing", async () => {
|
|
415
|
-
const largeBody = JSON.stringify({ events: [], payload: "x".repeat(70 * 1024) });
|
|
416
|
-
const { res, onEvents } = await invokeWebhook({ body: largeBody });
|
|
417
|
-
expect(res.status).toHaveBeenCalledWith(413);
|
|
418
|
-
expect(res.json).toHaveBeenCalledWith({ error: "Payload too large" });
|
|
419
|
-
expect(onEvents).not.toHaveBeenCalled();
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
it("rejects missing signature when events are non-empty", async () => {
|
|
423
|
-
const { res, onEvents } = await invokeWebhook({
|
|
424
|
-
body: JSON.stringify({ events: [{ type: "message" }] }),
|
|
425
|
-
headers: {},
|
|
426
|
-
autoSign: false,
|
|
427
|
-
});
|
|
428
|
-
expect(res.status).toHaveBeenCalledWith(400);
|
|
429
|
-
expect(res.json).toHaveBeenCalledWith({ error: "Missing X-Line-Signature header" });
|
|
430
|
-
expect(onEvents).not.toHaveBeenCalled();
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
it("rejects signed requests when raw body is missing", async () => {
|
|
434
|
-
const { res, onEvents } = await invokeWebhook({
|
|
435
|
-
body: { events: [{ type: "message" }] },
|
|
436
|
-
headers: { "x-line-signature": "signed" },
|
|
437
|
-
});
|
|
438
|
-
expect(res.status).toHaveBeenCalledWith(400);
|
|
439
|
-
expect(res.json).toHaveBeenCalledWith({
|
|
440
|
-
error: "Missing raw request body for signature verification",
|
|
441
|
-
});
|
|
442
|
-
expect(onEvents).not.toHaveBeenCalled();
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
it("uses the signed raw body instead of a pre-parsed req.body object", async () => {
|
|
446
|
-
await expectSignedRawBodyWins({
|
|
447
|
-
rawBody: JSON.stringify({
|
|
448
|
-
events: [{ type: "message", source: { userId: "signed-user" } }],
|
|
449
|
-
}),
|
|
450
|
-
signedUserId: "signed-user",
|
|
451
|
-
});
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
it("uses signed raw buffer body instead of a pre-parsed req.body object", async () => {
|
|
455
|
-
await expectSignedRawBodyWins({
|
|
456
|
-
rawBody: Buffer.from(
|
|
457
|
-
JSON.stringify({
|
|
458
|
-
events: [{ type: "message", source: { userId: "signed-buffer-user" } }],
|
|
459
|
-
}),
|
|
460
|
-
"utf-8",
|
|
461
|
-
),
|
|
462
|
-
signedUserId: "signed-buffer-user",
|
|
463
|
-
});
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
it("rejects invalid signed raw JSON even when req.body is a valid object", async () => {
|
|
467
|
-
const onEvents = vi.fn(async () => {});
|
|
468
|
-
const rawBody = "not-json";
|
|
469
|
-
const middleware = createLineWebhookMiddleware({
|
|
470
|
-
channelSecret: SECRET,
|
|
471
|
-
onEvents,
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
const req = {
|
|
475
|
-
headers: { "x-line-signature": sign(rawBody, SECRET) },
|
|
476
|
-
rawBody,
|
|
477
|
-
body: { events: [{ type: "message" }] },
|
|
478
|
-
} as any;
|
|
479
|
-
const res = createMiddlewareRes();
|
|
480
|
-
|
|
481
|
-
await middleware(req, res, {} as any);
|
|
482
|
-
|
|
483
|
-
expect(res.status).toHaveBeenCalledWith(400);
|
|
484
|
-
expect(res.json).toHaveBeenCalledWith({ error: "Invalid webhook payload" });
|
|
485
|
-
expect(onEvents).not.toHaveBeenCalled();
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
it("returns 500 when event processing fails and does not acknowledge with 200", async () => {
|
|
489
|
-
const onEvents = vi.fn(async () => {
|
|
490
|
-
throw new Error("boom");
|
|
491
|
-
});
|
|
492
|
-
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
|
493
|
-
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
|
|
494
|
-
const middleware = createLineWebhookMiddleware({
|
|
495
|
-
channelSecret: SECRET,
|
|
496
|
-
onEvents,
|
|
497
|
-
runtime,
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
const req = {
|
|
501
|
-
headers: { "x-line-signature": sign(rawBody, SECRET) },
|
|
502
|
-
body: rawBody,
|
|
503
|
-
} as any;
|
|
504
|
-
const res = createMiddlewareRes();
|
|
505
|
-
|
|
506
|
-
await middleware(req, res, {} as any);
|
|
507
|
-
|
|
508
|
-
expect(res.status).toHaveBeenCalledWith(500);
|
|
509
|
-
expect(res.status).not.toHaveBeenCalledWith(200);
|
|
510
|
-
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
|
|
511
|
-
expect(runtime.error).toHaveBeenCalled();
|
|
512
|
-
});
|
|
513
|
-
});
|
package/src/webhook-node.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import type { webhook } from "@line/bot-sdk";
|
|
3
|
-
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
|
4
|
-
import {
|
|
5
|
-
isRequestBodyLimitError,
|
|
6
|
-
readRequestBodyWithLimit,
|
|
7
|
-
requestBodyErrorToText,
|
|
8
|
-
} from "openclaw/plugin-sdk/webhook-request-guards";
|
|
9
|
-
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
|
|
10
|
-
|
|
11
|
-
const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
|
12
|
-
const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
|
|
13
|
-
const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000;
|
|
14
|
-
|
|
15
|
-
export async function readLineWebhookRequestBody(
|
|
16
|
-
req: IncomingMessage,
|
|
17
|
-
maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES,
|
|
18
|
-
timeoutMs = LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
|
|
19
|
-
): Promise<string> {
|
|
20
|
-
return await readRequestBodyWithLimit(req, {
|
|
21
|
-
maxBytes,
|
|
22
|
-
timeoutMs,
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
type ReadBodyFn = (req: IncomingMessage, maxBytes: number, timeoutMs?: number) => Promise<string>;
|
|
27
|
-
|
|
28
|
-
export function createLineNodeWebhookHandler(params: {
|
|
29
|
-
channelSecret: string;
|
|
30
|
-
bot: { handleWebhook: (body: webhook.CallbackRequest) => Promise<void> };
|
|
31
|
-
runtime: RuntimeEnv;
|
|
32
|
-
readBody?: ReadBodyFn;
|
|
33
|
-
maxBodyBytes?: number;
|
|
34
|
-
onRequestAuthenticated?: () => void;
|
|
35
|
-
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
36
|
-
const maxBodyBytes = params.maxBodyBytes ?? LINE_WEBHOOK_MAX_BODY_BYTES;
|
|
37
|
-
const readBody = params.readBody ?? readLineWebhookRequestBody;
|
|
38
|
-
|
|
39
|
-
return async (req: IncomingMessage, res: ServerResponse) => {
|
|
40
|
-
if (req.method === "GET" || req.method === "HEAD") {
|
|
41
|
-
if (req.method === "HEAD") {
|
|
42
|
-
res.statusCode = 204;
|
|
43
|
-
res.end();
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
res.statusCode = 200;
|
|
47
|
-
res.setHeader("Content-Type", "text/plain");
|
|
48
|
-
res.end("OK");
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (req.method !== "POST") {
|
|
53
|
-
res.statusCode = 405;
|
|
54
|
-
res.setHeader("Allow", "GET, HEAD, POST");
|
|
55
|
-
res.setHeader("Content-Type", "application/json");
|
|
56
|
-
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
const signatureHeader = req.headers["x-line-signature"];
|
|
62
|
-
const signature =
|
|
63
|
-
typeof signatureHeader === "string"
|
|
64
|
-
? signatureHeader.trim()
|
|
65
|
-
: Array.isArray(signatureHeader)
|
|
66
|
-
? (signatureHeader[0] ?? "").trim()
|
|
67
|
-
: "";
|
|
68
|
-
|
|
69
|
-
if (!signature) {
|
|
70
|
-
logVerbose("line: webhook missing X-Line-Signature header");
|
|
71
|
-
res.statusCode = 400;
|
|
72
|
-
res.setHeader("Content-Type", "application/json");
|
|
73
|
-
res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const rawBody = await readBody(
|
|
78
|
-
req,
|
|
79
|
-
Math.min(maxBodyBytes, LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES),
|
|
80
|
-
LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
if (!validateLineSignature(rawBody, signature, params.channelSecret)) {
|
|
84
|
-
logVerbose("line: webhook signature validation failed");
|
|
85
|
-
res.statusCode = 401;
|
|
86
|
-
res.setHeader("Content-Type", "application/json");
|
|
87
|
-
res.end(JSON.stringify({ error: "Invalid signature" }));
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const body = parseLineWebhookBody(rawBody);
|
|
92
|
-
|
|
93
|
-
if (!body) {
|
|
94
|
-
res.statusCode = 400;
|
|
95
|
-
res.setHeader("Content-Type", "application/json");
|
|
96
|
-
res.end(JSON.stringify({ error: "Invalid webhook payload" }));
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
params.onRequestAuthenticated?.();
|
|
101
|
-
|
|
102
|
-
if (body.events && body.events.length > 0) {
|
|
103
|
-
logVerbose(`line: received ${body.events.length} webhook events`);
|
|
104
|
-
await params.bot.handleWebhook(body);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
res.statusCode = 200;
|
|
108
|
-
res.setHeader("Content-Type", "application/json");
|
|
109
|
-
res.end(JSON.stringify({ status: "ok" }));
|
|
110
|
-
} catch (err) {
|
|
111
|
-
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
|
112
|
-
res.statusCode = 413;
|
|
113
|
-
res.setHeader("Content-Type", "application/json");
|
|
114
|
-
res.end(JSON.stringify({ error: "Payload too large" }));
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
|
118
|
-
res.statusCode = 408;
|
|
119
|
-
res.setHeader("Content-Type", "application/json");
|
|
120
|
-
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
params.runtime.error?.(danger(`line webhook error: ${String(err)}`));
|
|
124
|
-
if (!res.headersSent) {
|
|
125
|
-
res.statusCode = 500;
|
|
126
|
-
res.setHeader("Content-Type", "application/json");
|
|
127
|
-
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
}
|
package/src/webhook-utils.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import type { webhook } from "@line/bot-sdk";
|
|
2
|
-
export { validateLineSignature } from "./signature.js";
|
|
3
|
-
|
|
4
|
-
export function parseLineWebhookBody(rawBody: string): webhook.CallbackRequest | null {
|
|
5
|
-
try {
|
|
6
|
-
return JSON.parse(rawBody) as webhook.CallbackRequest;
|
|
7
|
-
} catch {
|
|
8
|
-
return null;
|
|
9
|
-
}
|
|
10
|
-
}
|