@openclaw/line 2026.5.2-beta.2 → 2026.5.3-beta.2

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.
Files changed (109) hide show
  1. package/dist/accounts-UXAvdBo_.js +105 -0
  2. package/dist/api.js +11 -0
  3. package/dist/basic-cards-7DTG6E-A.js +307 -0
  4. package/dist/card-command-UlPc6Vuc.js +240 -0
  5. package/dist/channel-Rls9s60K.js +601 -0
  6. package/dist/channel-plugin-api.js +2 -0
  7. package/dist/channel.runtime-bXoj8_Sl.js +4 -0
  8. package/dist/contract-api.js +2 -0
  9. package/dist/index.js +45 -0
  10. package/dist/markdown-to-line-CCWK6PJh.js +789 -0
  11. package/dist/monitor-B4_RxHc0.js +1523 -0
  12. package/dist/monitor.runtime-DyxYlrM9.js +2 -0
  13. package/dist/outbound-media-BfhBmWq5.js +55 -0
  14. package/dist/outbound.runtime-B-luYwEB.js +2 -0
  15. package/dist/probe-D9BPzb2n.js +30 -0
  16. package/dist/probe.runtime-N91OWW1J.js +2 -0
  17. package/dist/quick-reply-fallback-HskUUWsn.js +55 -0
  18. package/dist/reply-payload-transform-CFGTs9ln.js +713 -0
  19. package/dist/runtime-api.js +277 -0
  20. package/dist/schedule-cards-BwQChTdM.js +359 -0
  21. package/dist/secret-contract-api.js +5 -0
  22. package/dist/setup-api.js +2 -0
  23. package/dist/setup-entry.js +11 -0
  24. package/dist/setup-surface-DCH7CE-y.js +281 -0
  25. package/package.json +14 -6
  26. package/api.ts +0 -11
  27. package/channel-plugin-api.ts +0 -1
  28. package/contract-api.ts +0 -5
  29. package/index.ts +0 -54
  30. package/runtime-api.ts +0 -187
  31. package/secret-contract-api.ts +0 -4
  32. package/setup-api.ts +0 -2
  33. package/setup-entry.ts +0 -9
  34. package/src/account-helpers.ts +0 -16
  35. package/src/accounts.test.ts +0 -290
  36. package/src/accounts.ts +0 -187
  37. package/src/actions.ts +0 -61
  38. package/src/auto-reply-delivery.test.ts +0 -248
  39. package/src/auto-reply-delivery.ts +0 -200
  40. package/src/bindings.ts +0 -65
  41. package/src/bot-access.ts +0 -48
  42. package/src/bot-handlers.test.ts +0 -1089
  43. package/src/bot-handlers.ts +0 -642
  44. package/src/bot-message-context.test.ts +0 -405
  45. package/src/bot-message-context.ts +0 -581
  46. package/src/bot.ts +0 -70
  47. package/src/card-command.ts +0 -347
  48. package/src/channel-access-token.ts +0 -14
  49. package/src/channel-api.ts +0 -17
  50. package/src/channel-setup-status.contract.test.ts +0 -70
  51. package/src/channel-shared.ts +0 -48
  52. package/src/channel.logout.test.ts +0 -145
  53. package/src/channel.runtime.ts +0 -3
  54. package/src/channel.sendPayload.test.ts +0 -514
  55. package/src/channel.setup.ts +0 -11
  56. package/src/channel.status.test.ts +0 -63
  57. package/src/channel.ts +0 -154
  58. package/src/config-adapter.ts +0 -29
  59. package/src/config-schema.ts +0 -57
  60. package/src/download.test.ts +0 -133
  61. package/src/download.ts +0 -87
  62. package/src/flex-templates/basic-cards.ts +0 -395
  63. package/src/flex-templates/common.ts +0 -20
  64. package/src/flex-templates/media-control-cards.ts +0 -555
  65. package/src/flex-templates/message.ts +0 -13
  66. package/src/flex-templates/schedule-cards.ts +0 -467
  67. package/src/flex-templates/types.ts +0 -22
  68. package/src/flex-templates.ts +0 -32
  69. package/src/gateway.ts +0 -129
  70. package/src/group-keys.test.ts +0 -123
  71. package/src/group-keys.ts +0 -65
  72. package/src/group-policy.ts +0 -22
  73. package/src/markdown-to-line.test.ts +0 -348
  74. package/src/markdown-to-line.ts +0 -416
  75. package/src/message-cards.test.ts +0 -204
  76. package/src/monitor.lifecycle.test.ts +0 -421
  77. package/src/monitor.runtime.ts +0 -1
  78. package/src/monitor.ts +0 -506
  79. package/src/outbound-media.test.ts +0 -189
  80. package/src/outbound-media.ts +0 -120
  81. package/src/outbound.runtime.ts +0 -12
  82. package/src/outbound.ts +0 -356
  83. package/src/probe.contract.test.ts +0 -9
  84. package/src/probe.runtime.ts +0 -1
  85. package/src/probe.ts +0 -34
  86. package/src/quick-reply-fallback.ts +0 -10
  87. package/src/reply-chunks.test.ts +0 -179
  88. package/src/reply-chunks.ts +0 -110
  89. package/src/reply-payload-transform.test.ts +0 -387
  90. package/src/reply-payload-transform.ts +0 -317
  91. package/src/rich-menu.test.ts +0 -310
  92. package/src/rich-menu.ts +0 -326
  93. package/src/runtime.ts +0 -32
  94. package/src/send.test.ts +0 -346
  95. package/src/send.ts +0 -489
  96. package/src/setup-core.ts +0 -149
  97. package/src/setup-runtime-api.ts +0 -9
  98. package/src/setup-surface.test.ts +0 -474
  99. package/src/setup-surface.ts +0 -227
  100. package/src/signature.test.ts +0 -34
  101. package/src/signature.ts +0 -24
  102. package/src/status.ts +0 -37
  103. package/src/template-messages.ts +0 -333
  104. package/src/types.ts +0 -128
  105. package/src/webhook-node.test.ts +0 -513
  106. package/src/webhook-node.ts +0 -131
  107. package/src/webhook-utils.ts +0 -10
  108. package/src/webhook.ts +0 -111
  109. package/tsconfig.json +0 -16
package/src/monitor.ts DELETED
@@ -1,506 +0,0 @@
1
- import type { webhook } from "@line/bot-sdk";
2
- import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
4
- import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch";
5
- import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
6
- import {
7
- danger,
8
- logVerbose,
9
- waitForAbortSignal,
10
- type RuntimeEnv,
11
- } from "openclaw/plugin-sdk/runtime-env";
12
- import {
13
- isRequestBodyLimitError,
14
- normalizePluginHttpPath,
15
- registerWebhookTargetWithPluginRoute,
16
- requestBodyErrorToText,
17
- resolveSingleWebhookTarget,
18
- } from "openclaw/plugin-sdk/webhook-ingress";
19
- import {
20
- beginWebhookRequestPipelineOrReject,
21
- createWebhookInFlightLimiter,
22
- } from "openclaw/plugin-sdk/webhook-request-guards";
23
- import { resolveDefaultLineAccountId } from "./accounts.js";
24
- import { deliverLineAutoReply } from "./auto-reply-delivery.js";
25
- import { createLineBot } from "./bot.js";
26
- import { processLineMessage } from "./markdown-to-line.js";
27
- import { sendLineReplyChunks } from "./reply-chunks.js";
28
- import { getLineRuntime } from "./runtime.js";
29
- import {
30
- createFlexMessage,
31
- createImageMessage,
32
- createLocationMessage,
33
- createQuickReplyItems,
34
- createTextMessageWithQuickReplies,
35
- getUserDisplayName,
36
- pushMessageLine,
37
- pushMessagesLine,
38
- pushTextMessageWithQuickReplies,
39
- replyMessageLine,
40
- showLoadingAnimation,
41
- } from "./send.js";
42
- import { buildTemplateMessageFromPayload } from "./template-messages.js";
43
- import type { LineChannelData, ResolvedLineAccount } from "./types.js";
44
- import { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./webhook-node.js";
45
- import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
46
-
47
- export interface MonitorLineProviderOptions {
48
- channelAccessToken: string;
49
- channelSecret: string;
50
- accountId?: string;
51
- config: OpenClawConfig;
52
- runtime: RuntimeEnv;
53
- abortSignal?: AbortSignal;
54
- webhookUrl?: string;
55
- webhookPath?: string;
56
- }
57
-
58
- export interface LineProviderMonitor {
59
- account: ResolvedLineAccount;
60
- handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
61
- stop: () => void;
62
- }
63
-
64
- const runtimeState = new Map<
65
- string,
66
- {
67
- running: boolean;
68
- lastStartAt: number | null;
69
- lastStopAt: number | null;
70
- lastError: string | null;
71
- lastInboundAt?: number | null;
72
- lastOutboundAt?: number | null;
73
- }
74
- >();
75
- const lineWebhookInFlightLimiter = createWebhookInFlightLimiter();
76
- const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
77
- const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000;
78
-
79
- type LineWebhookTarget = {
80
- accountId: string;
81
- bot: ReturnType<typeof createLineBot>;
82
- channelSecret: string;
83
- path: string;
84
- runtime: RuntimeEnv;
85
- };
86
-
87
- const lineWebhookTargets = new Map<string, LineWebhookTarget[]>();
88
-
89
- function recordChannelRuntimeState(params: {
90
- channel: string;
91
- accountId: string;
92
- state: Partial<{
93
- running: boolean;
94
- lastStartAt: number | null;
95
- lastStopAt: number | null;
96
- lastError: string | null;
97
- lastInboundAt: number | null;
98
- lastOutboundAt: number | null;
99
- }>;
100
- }): void {
101
- const key = `${params.channel}:${params.accountId}`;
102
- const existing = runtimeState.get(key) ?? {
103
- running: false,
104
- lastStartAt: null,
105
- lastStopAt: null,
106
- lastError: null,
107
- };
108
- runtimeState.set(key, { ...existing, ...params.state });
109
- }
110
-
111
- export function getLineRuntimeState(accountId: string) {
112
- return runtimeState.get(`line:${accountId}`);
113
- }
114
-
115
- export function clearLineRuntimeStateForTests() {
116
- runtimeState.clear();
117
- }
118
-
119
- function startLineLoadingKeepalive(params: {
120
- cfg: OpenClawConfig;
121
- userId: string;
122
- accountId?: string;
123
- intervalMs?: number;
124
- loadingSeconds?: number;
125
- }): () => void {
126
- const intervalMs = params.intervalMs ?? 18_000;
127
- const loadingSeconds = params.loadingSeconds ?? 20;
128
- let stopped = false;
129
-
130
- const trigger = () => {
131
- if (stopped) {
132
- return;
133
- }
134
- void showLoadingAnimation(params.userId, {
135
- cfg: params.cfg,
136
- accountId: params.accountId,
137
- loadingSeconds,
138
- }).catch(() => {});
139
- };
140
-
141
- trigger();
142
- const timer = setInterval(trigger, intervalMs);
143
-
144
- return () => {
145
- if (stopped) {
146
- return;
147
- }
148
- stopped = true;
149
- clearInterval(timer);
150
- };
151
- }
152
-
153
- export async function monitorLineProvider(
154
- opts: MonitorLineProviderOptions,
155
- ): Promise<LineProviderMonitor> {
156
- const {
157
- channelAccessToken,
158
- channelSecret,
159
- accountId,
160
- config,
161
- runtime,
162
- abortSignal,
163
- webhookPath,
164
- } = opts;
165
- const resolvedAccountId = accountId ?? resolveDefaultLineAccountId(config);
166
- const token = channelAccessToken.trim();
167
- const secret = channelSecret.trim();
168
-
169
- if (!token) {
170
- throw new Error("LINE webhook mode requires a non-empty channel access token.");
171
- }
172
- if (!secret) {
173
- throw new Error("LINE webhook mode requires a non-empty channel secret.");
174
- }
175
-
176
- recordChannelRuntimeState({
177
- channel: "line",
178
- accountId: resolvedAccountId,
179
- state: {
180
- running: true,
181
- lastStartAt: Date.now(),
182
- },
183
- });
184
-
185
- const bot = createLineBot({
186
- channelAccessToken: token,
187
- channelSecret: secret,
188
- accountId,
189
- runtime,
190
- config,
191
- onMessage: async (ctx) => {
192
- if (!ctx) {
193
- return;
194
- }
195
-
196
- const { ctxPayload, replyToken, route } = ctx;
197
-
198
- recordChannelRuntimeState({
199
- channel: "line",
200
- accountId: resolvedAccountId,
201
- state: {
202
- lastInboundAt: Date.now(),
203
- },
204
- });
205
-
206
- const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
207
-
208
- const displayNamePromise = ctx.userId
209
- ? getUserDisplayName(ctx.userId, { cfg: config, accountId: ctx.accountId })
210
- : Promise.resolve(ctxPayload.From);
211
-
212
- const stopLoading = shouldShowLoading
213
- ? startLineLoadingKeepalive({
214
- cfg: config,
215
- userId: ctx.userId!,
216
- accountId: ctx.accountId,
217
- })
218
- : null;
219
-
220
- const displayName = await displayNamePromise;
221
- logVerbose(`line: received message from ${displayName} (${ctxPayload.From})`);
222
-
223
- try {
224
- const textLimit = 5000;
225
- let replyTokenUsed = false;
226
- const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
227
- cfg: config,
228
- agentId: route.agentId,
229
- channel: "line",
230
- accountId: route.accountId,
231
- });
232
-
233
- const core = getLineRuntime();
234
- const turnResult = await core.channel.turn.run({
235
- channel: "line",
236
- accountId: route.accountId,
237
- raw: ctx,
238
- adapter: {
239
- ingest: () => ({
240
- id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
241
- rawText: ctxPayload.RawBody ?? ctxPayload.BodyForAgent ?? "",
242
- }),
243
- resolveTurn: () => ({
244
- cfg: config,
245
- channel: "line",
246
- accountId: route.accountId,
247
- agentId: route.agentId,
248
- routeSessionKey: route.sessionKey,
249
- storePath: ctx.turn.storePath,
250
- ctxPayload,
251
- recordInboundSession: core.channel.session.recordInboundSession,
252
- dispatchReplyWithBufferedBlockDispatcher:
253
- core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
254
- record: ctx.turn.record,
255
- dispatcherOptions: {
256
- ...replyPipeline,
257
- },
258
- replyOptions: {
259
- onModelSelected,
260
- },
261
- delivery: {
262
- deliver: async (payload) => {
263
- const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
264
-
265
- if (ctx.userId && !ctx.isGroup) {
266
- void showLoadingAnimation(ctx.userId, {
267
- cfg: config,
268
- accountId: ctx.accountId,
269
- }).catch(() => {});
270
- }
271
-
272
- const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({
273
- payload,
274
- lineData,
275
- to: ctxPayload.From,
276
- replyToken,
277
- replyTokenUsed,
278
- accountId: ctx.accountId,
279
- cfg: config,
280
- textLimit,
281
- deps: {
282
- buildTemplateMessageFromPayload,
283
- processLineMessage,
284
- chunkMarkdownText,
285
- sendLineReplyChunks,
286
- replyMessageLine,
287
- pushMessageLine,
288
- pushTextMessageWithQuickReplies,
289
- createQuickReplyItems,
290
- createTextMessageWithQuickReplies,
291
- pushMessagesLine,
292
- createFlexMessage,
293
- createImageMessage,
294
- createLocationMessage,
295
- onReplyError: (replyErr) => {
296
- logVerbose(
297
- `line: reply token failed, falling back to push: ${String(replyErr)}`,
298
- );
299
- },
300
- },
301
- });
302
- replyTokenUsed = nextReplyTokenUsed;
303
-
304
- recordChannelRuntimeState({
305
- channel: "line",
306
- accountId: resolvedAccountId,
307
- state: {
308
- lastOutboundAt: Date.now(),
309
- },
310
- });
311
- },
312
- onError: (err, info) => {
313
- runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
314
- },
315
- },
316
- }),
317
- },
318
- });
319
- const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
320
- if (!hasFinalInboundReplyDispatch(dispatchResult)) {
321
- logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
322
- }
323
- } catch (err) {
324
- runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
325
-
326
- if (replyToken) {
327
- try {
328
- await replyMessageLine(
329
- replyToken,
330
- [{ type: "text", text: "Sorry, I encountered an error processing your message." }],
331
- { cfg: config, accountId: ctx.accountId },
332
- );
333
- } catch (replyErr) {
334
- runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`));
335
- }
336
- }
337
- } finally {
338
- stopLoading?.();
339
- }
340
- },
341
- });
342
-
343
- const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook";
344
- const createScopedLineWebhookHandler = (target: LineWebhookTarget) =>
345
- createLineNodeWebhookHandler({
346
- channelSecret: target.channelSecret,
347
- bot: target.bot,
348
- runtime: target.runtime,
349
- });
350
- const { unregister: unregisterHttp } = registerWebhookTargetWithPluginRoute({
351
- targetsByPath: lineWebhookTargets,
352
- target: {
353
- accountId: resolvedAccountId,
354
- bot,
355
- channelSecret: secret,
356
- path: normalizedPath,
357
- runtime,
358
- },
359
- route: {
360
- auth: "plugin",
361
- pluginId: "line",
362
- accountId: resolvedAccountId,
363
- log: (msg) => logVerbose(msg),
364
- handler: async (req, res) => {
365
- const targets = lineWebhookTargets.get(normalizedPath) ?? [];
366
- const firstTarget = targets[0];
367
- if (req.method !== "POST") {
368
- if (!firstTarget) {
369
- res.statusCode = 404;
370
- res.end("Not Found");
371
- return;
372
- }
373
- await createScopedLineWebhookHandler(firstTarget)(req, res);
374
- return;
375
- }
376
-
377
- const requestLifecycle = beginWebhookRequestPipelineOrReject({
378
- req,
379
- res,
380
- inFlightLimiter: lineWebhookInFlightLimiter,
381
- inFlightKey: `line:${normalizedPath}`,
382
- });
383
- if (!requestLifecycle.ok) {
384
- return;
385
- }
386
-
387
- try {
388
- const signatureHeader = req.headers["x-line-signature"];
389
- const signature =
390
- typeof signatureHeader === "string"
391
- ? signatureHeader.trim()
392
- : Array.isArray(signatureHeader)
393
- ? (signatureHeader[0] ?? "").trim()
394
- : "";
395
-
396
- if (!signature) {
397
- logVerbose("line: webhook missing X-Line-Signature header");
398
- res.statusCode = 400;
399
- res.setHeader("Content-Type", "application/json");
400
- res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
401
- return;
402
- }
403
-
404
- const rawBody = await readLineWebhookRequestBody(
405
- req,
406
- LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES,
407
- LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
408
- );
409
- const match = resolveSingleWebhookTarget(targets, (target) =>
410
- validateLineSignature(rawBody, signature, target.channelSecret),
411
- );
412
- if (match.kind === "none") {
413
- logVerbose("line: webhook signature validation failed");
414
- res.statusCode = 401;
415
- res.setHeader("Content-Type", "application/json");
416
- res.end(JSON.stringify({ error: "Invalid signature" }));
417
- return;
418
- }
419
- if (match.kind === "ambiguous") {
420
- logVerbose("line: webhook signature matched multiple accounts");
421
- res.statusCode = 401;
422
- res.setHeader("Content-Type", "application/json");
423
- res.end(JSON.stringify({ error: "Ambiguous webhook target" }));
424
- return;
425
- }
426
-
427
- const body = parseLineWebhookBody(rawBody);
428
- if (!body) {
429
- res.statusCode = 400;
430
- res.setHeader("Content-Type", "application/json");
431
- res.end(JSON.stringify({ error: "Invalid webhook payload" }));
432
- return;
433
- }
434
-
435
- requestLifecycle.release();
436
-
437
- if (body.events && body.events.length > 0) {
438
- logVerbose(`line: received ${body.events.length} webhook events`);
439
- await match.target.bot.handleWebhook(body);
440
- }
441
-
442
- res.statusCode = 200;
443
- res.setHeader("Content-Type", "application/json");
444
- res.end(JSON.stringify({ status: "ok" }));
445
- } catch (err) {
446
- if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
447
- res.statusCode = 413;
448
- res.setHeader("Content-Type", "application/json");
449
- res.end(JSON.stringify({ error: "Payload too large" }));
450
- return;
451
- }
452
- if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
453
- res.statusCode = 408;
454
- res.setHeader("Content-Type", "application/json");
455
- res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
456
- return;
457
- }
458
- runtime.error?.(danger(`line webhook error: ${String(err)}`));
459
- if (!res.headersSent) {
460
- res.statusCode = 500;
461
- res.setHeader("Content-Type", "application/json");
462
- res.end(JSON.stringify({ error: "Internal server error" }));
463
- }
464
- } finally {
465
- requestLifecycle.release();
466
- }
467
- },
468
- },
469
- });
470
-
471
- logVerbose(`line: registered webhook handler at ${normalizedPath}`);
472
-
473
- let stopped = false;
474
- const stopHandler = () => {
475
- if (stopped) {
476
- return;
477
- }
478
- stopped = true;
479
- logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
480
- unregisterHttp();
481
- recordChannelRuntimeState({
482
- channel: "line",
483
- accountId: resolvedAccountId,
484
- state: {
485
- running: false,
486
- lastStopAt: Date.now(),
487
- },
488
- });
489
- };
490
-
491
- if (abortSignal?.aborted) {
492
- stopHandler();
493
- } else if (abortSignal) {
494
- abortSignal.addEventListener("abort", stopHandler, { once: true });
495
- await waitForAbortSignal(abortSignal);
496
- }
497
-
498
- return {
499
- account: bot.account,
500
- handleWebhook: bot.handleWebhook,
501
- stop: () => {
502
- stopHandler();
503
- abortSignal?.removeEventListener("abort", stopHandler);
504
- },
505
- };
506
- }
@@ -1,189 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- const ssrfMocks = vi.hoisted(() => ({
4
- resolvePinnedHostnameWithPolicy: vi.fn(),
5
- }));
6
-
7
- vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
8
- resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy,
9
- }));
10
-
11
- import {
12
- detectLineMediaKind,
13
- resolveLineOutboundMedia,
14
- validateLineMediaUrl,
15
- } from "./outbound-media.js";
16
-
17
- describe("validateLineMediaUrl", () => {
18
- beforeEach(() => {
19
- ssrfMocks.resolvePinnedHostnameWithPolicy.mockReset();
20
- ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({
21
- hostname: "example.com",
22
- addresses: ["93.184.216.34"],
23
- });
24
- });
25
-
26
- it("accepts HTTPS URL", async () => {
27
- await expect(validateLineMediaUrl("https://example.com/image.jpg")).resolves.toBeUndefined();
28
- expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("example.com", {
29
- policy: { allowPrivateNetwork: false },
30
- });
31
- });
32
-
33
- it("accepts uppercase HTTPS scheme", async () => {
34
- await expect(validateLineMediaUrl("HTTPS://EXAMPLE.COM/img.jpg")).resolves.toBeUndefined();
35
- expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("example.com", {
36
- policy: { allowPrivateNetwork: false },
37
- });
38
- });
39
-
40
- it("rejects HTTP URL", async () => {
41
- await expect(validateLineMediaUrl("http://example.com/image.jpg")).rejects.toThrow(
42
- /must use HTTPS/i,
43
- );
44
- expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
45
- });
46
-
47
- it("rejects URL longer than 2000 chars", async () => {
48
- const longUrl = `https://example.com/${"a".repeat(1981)}`;
49
- expect(longUrl.length).toBeGreaterThan(2000);
50
- await expect(validateLineMediaUrl(longUrl)).rejects.toThrow(/2000 chars or less/i);
51
- expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
52
- });
53
-
54
- it("rejects private-network targets through the shared SSRF policy", async () => {
55
- ssrfMocks.resolvePinnedHostnameWithPolicy.mockRejectedValueOnce(
56
- new Error("SSRF blocked private network target"),
57
- );
58
-
59
- await expect(validateLineMediaUrl("https://127.0.0.1/image.jpg")).rejects.toThrow(
60
- /private network/i,
61
- );
62
- expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("127.0.0.1", {
63
- policy: { allowPrivateNetwork: false },
64
- });
65
- });
66
- });
67
-
68
- describe("detectLineMediaKind", () => {
69
- it("maps image MIME to image", () => {
70
- expect(detectLineMediaKind("image/jpeg")).toBe("image");
71
- });
72
-
73
- it("maps uppercase image MIME to image", () => {
74
- expect(detectLineMediaKind("IMAGE/JPEG")).toBe("image");
75
- });
76
-
77
- it("maps video MIME to video", () => {
78
- expect(detectLineMediaKind("video/mp4")).toBe("video");
79
- });
80
-
81
- it("maps audio MIME to audio", () => {
82
- expect(detectLineMediaKind("audio/mpeg")).toBe("audio");
83
- });
84
-
85
- it("falls back unknown MIME to image", () => {
86
- expect(detectLineMediaKind("application/octet-stream")).toBe("image");
87
- });
88
- });
89
-
90
- describe("resolveLineOutboundMedia", () => {
91
- beforeEach(() => {
92
- ssrfMocks.resolvePinnedHostnameWithPolicy.mockReset();
93
- ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({
94
- hostname: "example.com",
95
- addresses: ["93.184.216.34"],
96
- });
97
- });
98
-
99
- it("respects explicit media kind without remote MIME probing", async () => {
100
- await expect(
101
- resolveLineOutboundMedia("https://example.com/download?id=123", { mediaKind: "video" }),
102
- ).resolves.toEqual({
103
- mediaUrl: "https://example.com/download?id=123",
104
- mediaKind: "video",
105
- });
106
- });
107
-
108
- it("preserves explicit video kind when a preview URL is provided", async () => {
109
- await expect(
110
- resolveLineOutboundMedia("https://example.com/download?id=123", {
111
- mediaKind: "video",
112
- previewImageUrl: "https://example.com/preview.jpg",
113
- }),
114
- ).resolves.toEqual({
115
- mediaUrl: "https://example.com/download?id=123",
116
- mediaKind: "video",
117
- previewImageUrl: "https://example.com/preview.jpg",
118
- });
119
- });
120
-
121
- it("infers audio kind from explicit duration metadata when mediaKind is omitted", async () => {
122
- await expect(
123
- resolveLineOutboundMedia("https://example.com/download?id=audio", {
124
- durationMs: 60000,
125
- }),
126
- ).resolves.toEqual({
127
- mediaUrl: "https://example.com/download?id=audio",
128
- mediaKind: "audio",
129
- durationMs: 60000,
130
- });
131
- });
132
-
133
- it("does not infer video from previewImageUrl alone", async () => {
134
- await expect(
135
- resolveLineOutboundMedia("https://example.com/image.jpg", {
136
- previewImageUrl: "https://example.com/preview.jpg",
137
- }),
138
- ).resolves.toEqual({
139
- mediaUrl: "https://example.com/image.jpg",
140
- mediaKind: "image",
141
- previewImageUrl: "https://example.com/preview.jpg",
142
- });
143
- });
144
-
145
- it("infers media kinds from known HTTPS file extensions", async () => {
146
- await expect(resolveLineOutboundMedia("https://example.com/audio.mp3")).resolves.toEqual({
147
- mediaUrl: "https://example.com/audio.mp3",
148
- mediaKind: "audio",
149
- });
150
- await expect(resolveLineOutboundMedia("https://example.com/video.mp4")).resolves.toEqual({
151
- mediaUrl: "https://example.com/video.mp4",
152
- mediaKind: "video",
153
- });
154
- await expect(resolveLineOutboundMedia("https://example.com/image.jpg")).resolves.toEqual({
155
- mediaUrl: "https://example.com/image.jpg",
156
- mediaKind: "image",
157
- });
158
- });
159
-
160
- it("validates previewImageUrl when provided", async () => {
161
- await expect(
162
- resolveLineOutboundMedia("https://example.com/video.mp4", {
163
- mediaKind: "video",
164
- previewImageUrl: "http://example.com/preview.jpg",
165
- }),
166
- ).rejects.toThrow(/must use HTTPS/i);
167
- });
168
-
169
- it("falls back to image when no explicit LINE media options or known extension are present", async () => {
170
- await expect(
171
- resolveLineOutboundMedia("https://example.com/download?id=audio"),
172
- ).resolves.toEqual({
173
- mediaUrl: "https://example.com/download?id=audio",
174
- mediaKind: "image",
175
- });
176
- });
177
-
178
- it("rejects local paths because LINE outbound media requires public HTTPS URLs", async () => {
179
- await expect(resolveLineOutboundMedia("./assets/image.jpg")).rejects.toThrow(
180
- /requires a public https url/i,
181
- );
182
- });
183
-
184
- it("rejects non-HTTPS URL explicitly", async () => {
185
- await expect(resolveLineOutboundMedia("http://example.com/image.jpg")).rejects.toThrow(
186
- /must use HTTPS/i,
187
- );
188
- });
189
- });