@kodelyth/line 2026.5.42 → 2026.6.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.
Files changed (90) hide show
  1. package/klaw.plugin.json +329 -2
  2. package/package.json +16 -4
  3. package/api.ts +0 -11
  4. package/channel-plugin-api.ts +0 -1
  5. package/contract-api.ts +0 -5
  6. package/index.ts +0 -53
  7. package/runtime-api.ts +0 -179
  8. package/secret-contract-api.ts +0 -4
  9. package/setup-api.ts +0 -2
  10. package/setup-entry.ts +0 -9
  11. package/src/account-helpers.ts +0 -16
  12. package/src/accounts.test.ts +0 -288
  13. package/src/accounts.ts +0 -187
  14. package/src/actions.ts +0 -61
  15. package/src/auto-reply-delivery.test.ts +0 -253
  16. package/src/auto-reply-delivery.ts +0 -200
  17. package/src/bindings.ts +0 -65
  18. package/src/bot-access.ts +0 -30
  19. package/src/bot-handlers.test.ts +0 -1094
  20. package/src/bot-handlers.ts +0 -620
  21. package/src/bot-message-context.test.ts +0 -420
  22. package/src/bot-message-context.ts +0 -586
  23. package/src/bot.ts +0 -66
  24. package/src/card-command.ts +0 -347
  25. package/src/channel-access-token.ts +0 -14
  26. package/src/channel-api.ts +0 -17
  27. package/src/channel-setup-status.contract.test.ts +0 -70
  28. package/src/channel-shared.ts +0 -48
  29. package/src/channel.logout.test.ts +0 -145
  30. package/src/channel.runtime.ts +0 -3
  31. package/src/channel.sendPayload.test.ts +0 -659
  32. package/src/channel.setup.ts +0 -11
  33. package/src/channel.status.test.ts +0 -63
  34. package/src/channel.ts +0 -155
  35. package/src/config-adapter.ts +0 -29
  36. package/src/config-schema.test.ts +0 -53
  37. package/src/config-schema.ts +0 -81
  38. package/src/download.test.ts +0 -164
  39. package/src/download.ts +0 -34
  40. package/src/flex-templates/basic-cards.ts +0 -395
  41. package/src/flex-templates/common.ts +0 -20
  42. package/src/flex-templates/media-control-cards.ts +0 -555
  43. package/src/flex-templates/message.ts +0 -13
  44. package/src/flex-templates/schedule-cards.ts +0 -467
  45. package/src/flex-templates/types.ts +0 -22
  46. package/src/flex-templates.ts +0 -32
  47. package/src/gateway.ts +0 -129
  48. package/src/group-keys.test.ts +0 -123
  49. package/src/group-keys.ts +0 -65
  50. package/src/group-policy.ts +0 -22
  51. package/src/markdown-to-line.test.ts +0 -348
  52. package/src/markdown-to-line.ts +0 -416
  53. package/src/message-cards.test.ts +0 -204
  54. package/src/monitor-durable.test.ts +0 -57
  55. package/src/monitor-durable.ts +0 -37
  56. package/src/monitor.lifecycle.test.ts +0 -499
  57. package/src/monitor.runtime.ts +0 -1
  58. package/src/monitor.ts +0 -507
  59. package/src/outbound-media.test.ts +0 -194
  60. package/src/outbound-media.ts +0 -120
  61. package/src/outbound.runtime.ts +0 -12
  62. package/src/outbound.ts +0 -427
  63. package/src/probe.contract.test.ts +0 -9
  64. package/src/probe.runtime.ts +0 -1
  65. package/src/probe.ts +0 -34
  66. package/src/quick-reply-fallback.ts +0 -10
  67. package/src/reply-chunks.test.ts +0 -180
  68. package/src/reply-chunks.ts +0 -110
  69. package/src/reply-payload-transform.test.ts +0 -392
  70. package/src/reply-payload-transform.ts +0 -317
  71. package/src/rich-menu.test.ts +0 -315
  72. package/src/rich-menu.ts +0 -326
  73. package/src/runtime.ts +0 -32
  74. package/src/send-receipt.ts +0 -32
  75. package/src/send.test.ts +0 -453
  76. package/src/send.ts +0 -531
  77. package/src/setup-core.ts +0 -149
  78. package/src/setup-runtime-api.ts +0 -9
  79. package/src/setup-surface.test.ts +0 -481
  80. package/src/setup-surface.ts +0 -229
  81. package/src/signature.test.ts +0 -34
  82. package/src/signature.ts +0 -24
  83. package/src/status.ts +0 -37
  84. package/src/template-messages.ts +0 -333
  85. package/src/types.ts +0 -130
  86. package/src/webhook-node.test.ts +0 -598
  87. package/src/webhook-node.ts +0 -155
  88. package/src/webhook-utils.ts +0 -10
  89. package/src/webhook.ts +0 -135
  90. package/tsconfig.json +0 -16
@@ -1,63 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import type { ChannelAccountSnapshot } from "../api.js";
3
- import { lineStatusAdapter } from "./status.js";
4
-
5
- function collectIssues(accounts: ChannelAccountSnapshot[]) {
6
- const collect = lineStatusAdapter.collectStatusIssues;
7
- if (!collect) {
8
- throw new Error("LINE plugin status collector is unavailable");
9
- }
10
- return collect(accounts);
11
- }
12
-
13
- describe("linePlugin status.collectStatusIssues", () => {
14
- it("does not warn when a sanitized snapshot is configured", () => {
15
- expect(
16
- collectIssues([
17
- {
18
- accountId: "default",
19
- configured: true,
20
- tokenSource: "env",
21
- },
22
- ]),
23
- ).toStrictEqual([]);
24
- });
25
-
26
- it("reports missing access token when the snapshot is unconfigured and tokenSource is none", () => {
27
- expect(
28
- collectIssues([
29
- {
30
- accountId: "default",
31
- configured: false,
32
- tokenSource: "none",
33
- },
34
- ]),
35
- ).toEqual([
36
- {
37
- channel: "line",
38
- accountId: "default",
39
- kind: "config",
40
- message: "LINE channel access token not configured",
41
- },
42
- ]);
43
- });
44
-
45
- it("reports missing secret when the snapshot is unconfigured but a token source exists", () => {
46
- expect(
47
- collectIssues([
48
- {
49
- accountId: "default",
50
- configured: false,
51
- tokenSource: "env",
52
- },
53
- ]),
54
- ).toEqual([
55
- {
56
- channel: "line",
57
- accountId: "default",
58
- kind: "config",
59
- message: "LINE channel secret not configured",
60
- },
61
- ]);
62
- });
63
- });
package/src/channel.ts DELETED
@@ -1,155 +0,0 @@
1
- import { createChatChannelPlugin } from "klaw/plugin-sdk/channel-core";
2
- import { createPairingPrefixStripper } from "klaw/plugin-sdk/channel-pairing";
3
- import { createRestrictSendersChannelSecurity } from "klaw/plugin-sdk/channel-policy";
4
- import { createEmptyChannelDirectoryAdapter } from "klaw/plugin-sdk/directory-runtime";
5
- import { createLazyRuntimeModule } from "klaw/plugin-sdk/lazy-runtime";
6
- import { resolveLineAccount } from "./accounts.js";
7
- import { lineBindingsAdapter } from "./bindings.js";
8
- import { type ChannelPlugin, type ResolvedLineAccount } from "./channel-api.js";
9
- import { lineChannelPluginCommon } from "./channel-shared.js";
10
- import { lineGatewayAdapter } from "./gateway.js";
11
- import { resolveLineGroupRequireMention } from "./group-policy.js";
12
- import { lineMessageAdapter, lineOutboundAdapter } from "./outbound.js";
13
- import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
14
- import { getLineRuntime } from "./runtime.js";
15
- import { lineSetupAdapter } from "./setup-core.js";
16
- import { lineSetupWizard } from "./setup-surface.js";
17
- import { lineStatusAdapter } from "./status.js";
18
-
19
- const loadLineChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
20
-
21
- const lineSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedLineAccount>({
22
- channelKey: "line",
23
- resolveDmPolicy: (account) => account.config.dmPolicy,
24
- resolveDmAllowFrom: (account) => account.config.allowFrom,
25
- resolveGroupPolicy: (account) => account.config.groupPolicy,
26
- surface: "LINE groups",
27
- openScope: "any member in groups",
28
- groupPolicyPath: "channels.line.groupPolicy",
29
- groupAllowFromPath: "channels.line.groupAllowFrom",
30
- mentionGated: false,
31
- policyPathSuffix: "dmPolicy",
32
- approveHint: "klaw pairing approve line <code>",
33
- normalizeDmEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
34
- });
35
-
36
- export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelPlugin({
37
- base: {
38
- id: "line",
39
- ...lineChannelPluginCommon,
40
- setupWizard: lineSetupWizard,
41
- groups: {
42
- resolveRequireMention: resolveLineGroupRequireMention,
43
- },
44
- messaging: {
45
- targetPrefixes: ["line"],
46
- normalizeTarget: (target) => {
47
- const trimmed = target.trim();
48
- if (!trimmed) {
49
- return undefined;
50
- }
51
- return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
52
- },
53
- resolveInboundConversation: lineBindingsAdapter.resolveInboundConversation,
54
- transformReplyPayload: ({ payload }) => {
55
- if (!payload.text || !hasLineDirectives(payload.text)) {
56
- return payload;
57
- }
58
- return parseLineDirectives(payload);
59
- },
60
- targetResolver: {
61
- looksLikeId: (id) => {
62
- const trimmed = id?.trim();
63
- if (!trimmed) {
64
- return false;
65
- }
66
- return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed);
67
- },
68
- hint: "<userId|groupId|roomId>",
69
- },
70
- },
71
- directory: createEmptyChannelDirectoryAdapter(),
72
- setup: lineSetupAdapter,
73
- status: lineStatusAdapter,
74
- gateway: lineGatewayAdapter,
75
- message: lineMessageAdapter,
76
- bindings: lineBindingsAdapter,
77
- conversationBindings: {
78
- defaultTopLevelPlacement: "current",
79
- },
80
- agentPrompt: {
81
- messageToolHints: () => [
82
- "",
83
- "### LINE Rich Messages",
84
- "LINE supports rich visual messages. Use these directives in your reply when appropriate:",
85
- "",
86
- "**Quick Replies** (bottom button suggestions):",
87
- " [[quick_replies: Option 1, Option 2, Option 3]]",
88
- "",
89
- "**Location** (map pin):",
90
- " [[location: Place Name | Address | latitude | longitude]]",
91
- "",
92
- "**Confirm Dialog** (yes/no prompt):",
93
- " [[confirm: Question text? | Yes Label | No Label]]",
94
- "",
95
- "**Button Menu** (title + text + buttons):",
96
- " [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
97
- "",
98
- "**Media Player Card** (music status):",
99
- " [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
100
- " - Status: 'playing' or 'paused' (optional)",
101
- "",
102
- "**Event Card** (calendar events, meetings):",
103
- " [[event: Event Title | Date | Time | Location | Description]]",
104
- " - Time, Location, Description are optional",
105
- "",
106
- "**Agenda Card** (multiple events/schedule):",
107
- " [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
108
- "",
109
- "**Device Control Card** (smart devices, TVs, etc.):",
110
- " [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
111
- "",
112
- "**Apple TV Remote** (full D-pad + transport):",
113
- " [[appletv_remote: Apple TV | Playing]]",
114
- "",
115
- "**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
116
- "",
117
- "When to use rich messages:",
118
- "- Use [[quick_replies:...]] when offering 2-4 clear options",
119
- "- Use [[confirm:...]] for yes/no decisions",
120
- "- Use [[buttons:...]] for menus with actions/links",
121
- "- Use [[location:...]] when sharing a place",
122
- "- Use [[media_player:...]] when showing what's playing",
123
- "- Use [[event:...]] for calendar event details",
124
- "- Use [[agenda:...]] for a day's schedule or event list",
125
- "- Use [[device:...]] for smart device status/controls",
126
- "- Tables/code in your response auto-convert to visual cards",
127
- ],
128
- },
129
- },
130
- pairing: {
131
- text: {
132
- idLabel: "lineUserId",
133
- message: "Klaw: your access has been approved.",
134
- normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i),
135
- notify: async ({ cfg, id, message }) => {
136
- const account = (getLineRuntime().channel.line?.resolveLineAccount ?? resolveLineAccount)({
137
- cfg,
138
- });
139
- if (!account.channelAccessToken) {
140
- throw new Error("LINE channel access token not configured");
141
- }
142
- const pushMessageLine =
143
- getLineRuntime().channel.line?.pushMessageLine ??
144
- (await loadLineChannelRuntime()).pushMessageLine;
145
- await pushMessageLine(id, message, {
146
- cfg,
147
- accountId: account.accountId,
148
- channelAccessToken: account.channelAccessToken,
149
- });
150
- },
151
- },
152
- },
153
- security: lineSecurityAdapter,
154
- outbound: lineOutboundAdapter,
155
- });
@@ -1,29 +0,0 @@
1
- import { createScopedChannelConfigAdapter } from "klaw/plugin-sdk/channel-config-helpers";
2
- import {
3
- listLineAccountIds,
4
- resolveDefaultLineAccountId,
5
- resolveLineAccount,
6
- type ResolvedLineAccount,
7
- } from "./channel-api.js";
8
-
9
- function normalizeLineAllowFrom(entry: string): string {
10
- return entry.replace(/^line:(?:user:)?/i, "");
11
- }
12
-
13
- export const lineConfigAdapter = createScopedChannelConfigAdapter<
14
- ResolvedLineAccount,
15
- ResolvedLineAccount
16
- >({
17
- sectionKey: "line",
18
- listAccountIds: listLineAccountIds,
19
- resolveAccount: (cfg, accountId) =>
20
- resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
21
- defaultAccountId: resolveDefaultLineAccountId,
22
- clearBaseFields: ["channelSecret", "tokenFile", "secretFile"],
23
- resolveAllowFrom: (account) => account.config.allowFrom,
24
- formatAllowFrom: (allowFrom) =>
25
- allowFrom
26
- .map((entry) => String(entry).trim())
27
- .filter(Boolean)
28
- .map(normalizeLineAllowFrom),
29
- });
@@ -1,53 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { LineConfigSchema } from "./config-schema.js";
3
-
4
- describe("LineConfigSchema", () => {
5
- it('rejects dmPolicy="open" without wildcard allowFrom', () => {
6
- const result = LineConfigSchema.safeParse({
7
- channelAccessToken: "token",
8
- channelSecret: "secret",
9
- dmPolicy: "open",
10
- });
11
-
12
- if (result.success) {
13
- throw new Error("Expected config validation to fail");
14
- }
15
- expect(result.error.issues).toHaveLength(1);
16
- expect(result.error.issues[0]?.path).toEqual(["allowFrom"]);
17
- expect(result.error.issues[0]?.message).toBe(
18
- 'channels.line.dmPolicy="open" requires channels.line.allowFrom to include "*"',
19
- );
20
- });
21
-
22
- it('accepts dmPolicy="open" with wildcard allowFrom', () => {
23
- const result = LineConfigSchema.safeParse({
24
- channelAccessToken: "token",
25
- channelSecret: "secret",
26
- dmPolicy: "open",
27
- allowFrom: ["*"],
28
- });
29
-
30
- expect(result.success).toBe(true);
31
- });
32
-
33
- it('rejects account dmPolicy="open" without wildcard allowFrom', () => {
34
- const result = LineConfigSchema.safeParse({
35
- accounts: {
36
- work: {
37
- channelAccessToken: "token",
38
- channelSecret: "secret",
39
- dmPolicy: "open",
40
- },
41
- },
42
- });
43
-
44
- if (result.success) {
45
- throw new Error("Expected account config validation to fail");
46
- }
47
- expect(result.error.issues).toHaveLength(1);
48
- expect(result.error.issues[0]?.path).toEqual(["accounts", "work", "allowFrom"]);
49
- expect(result.error.issues[0]?.message).toBe(
50
- 'channels.line.dmPolicy="open" requires channels.line.allowFrom to include "*"',
51
- );
52
- });
53
- });
@@ -1,81 +0,0 @@
1
- import {
2
- buildChannelConfigSchema,
3
- requireOpenAllowFrom,
4
- } from "klaw/plugin-sdk/channel-config-schema";
5
- import { requireChannelOpenAllowFrom } from "klaw/plugin-sdk/extension-shared";
6
- import { z } from "zod";
7
-
8
- const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]);
9
- const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
10
- const ThreadBindingsSchema = z
11
- .object({
12
- enabled: z.boolean().optional(),
13
- idleHours: z.number().optional(),
14
- maxAgeHours: z.number().optional(),
15
- spawnSessions: z.boolean().optional(),
16
- defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
17
- spawnSubagentSessions: z.boolean().optional(),
18
- spawnAcpSessions: z.boolean().optional(),
19
- })
20
- .strict();
21
-
22
- const LineCommonConfigSchemaBase = z.object({
23
- enabled: z.boolean().optional(),
24
- channelAccessToken: z.string().optional(),
25
- channelSecret: z.string().optional(),
26
- tokenFile: z.string().optional(),
27
- secretFile: z.string().optional(),
28
- name: z.string().optional(),
29
- allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
30
- groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
31
- dmPolicy: DmPolicySchema.optional().default("pairing"),
32
- groupPolicy: GroupPolicySchema.optional().default("allowlist"),
33
- responsePrefix: z.string().optional(),
34
- mediaMaxMb: z.number().optional(),
35
- webhookPath: z.string().optional(),
36
- threadBindings: ThreadBindingsSchema.optional(),
37
- });
38
-
39
- const LineGroupConfigSchema = z
40
- .object({
41
- enabled: z.boolean().optional(),
42
- allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
43
- requireMention: z.boolean().optional(),
44
- systemPrompt: z.string().optional(),
45
- skills: z.array(z.string()).optional(),
46
- })
47
- .strict();
48
-
49
- const LineAccountConfigSchema = LineCommonConfigSchemaBase.extend({
50
- groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
51
- })
52
- .strict()
53
- .superRefine((value, ctx) => {
54
- requireChannelOpenAllowFrom({
55
- channel: "line",
56
- policy: value.dmPolicy,
57
- allowFrom: value.allowFrom,
58
- ctx,
59
- requireOpenAllowFrom,
60
- });
61
- });
62
-
63
- export const LineConfigSchema = LineCommonConfigSchemaBase.extend({
64
- accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),
65
- defaultAccount: z.string().optional(),
66
- groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
67
- })
68
- .strict()
69
- .superRefine((value, ctx) => {
70
- requireChannelOpenAllowFrom({
71
- channel: "line",
72
- policy: value.dmPolicy,
73
- allowFrom: value.allowFrom,
74
- ctx,
75
- requireOpenAllowFrom,
76
- });
77
- });
78
-
79
- export const LineChannelConfigSchema = buildChannelConfigSchema(LineConfigSchema);
80
-
81
- export type LineConfigSchemaType = z.infer<typeof LineConfigSchema>;
@@ -1,164 +0,0 @@
1
- import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- const getMessageContentMock = vi.hoisted(() => vi.fn());
4
- const saveMediaStreamMock = vi.hoisted(() => vi.fn());
5
-
6
- vi.mock("@line/bot-sdk", () => ({
7
- messagingApi: {
8
- MessagingApiBlobClient: class {
9
- getMessageContent(messageId: string) {
10
- return getMessageContentMock(messageId);
11
- }
12
- },
13
- },
14
- }));
15
-
16
- vi.mock("klaw/plugin-sdk/runtime-env", () => ({
17
- createSubsystemLogger: () => {
18
- const logger = {
19
- debug: () => {},
20
- info: () => {},
21
- warn: () => {},
22
- error: () => {},
23
- child: () => logger,
24
- };
25
- return logger;
26
- },
27
- logVerbose: () => {},
28
- }));
29
-
30
- vi.mock("klaw/plugin-sdk/media-store", () => ({
31
- saveMediaStream: saveMediaStreamMock,
32
- }));
33
-
34
- let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
35
-
36
- async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
37
- for (const part of parts) {
38
- yield part;
39
- }
40
- }
41
-
42
- function saveMediaStreamCall(): unknown[] {
43
- const call = saveMediaStreamMock.mock.calls.at(0);
44
- if (!call) {
45
- throw new Error("Expected saveMediaStream call");
46
- }
47
- return call;
48
- }
49
-
50
- function detectMockContentType(buffer: Buffer, contentType?: string): string | undefined {
51
- if (buffer[0] === 0xff && buffer[1] === 0xd8) {
52
- return "image/jpeg";
53
- }
54
- if (buffer.toString("ascii", 4, 8) === "ftyp") {
55
- return buffer.toString("ascii", 8, 12) === "M4A " ? "audio/x-m4a" : "video/mp4";
56
- }
57
- return contentType;
58
- }
59
-
60
- describe("downloadLineMedia", () => {
61
- beforeAll(async () => {
62
- ({ downloadLineMedia } = await import("./download.js"));
63
- });
64
-
65
- afterAll(() => {
66
- vi.doUnmock("@line/bot-sdk");
67
- vi.doUnmock("klaw/plugin-sdk/runtime-env");
68
- vi.doUnmock("klaw/plugin-sdk/media-store");
69
- vi.resetModules();
70
- });
71
-
72
- beforeEach(() => {
73
- vi.restoreAllMocks();
74
- getMessageContentMock.mockReset();
75
- saveMediaStreamMock.mockReset();
76
- saveMediaStreamMock.mockImplementation(
77
- async (stream: AsyncIterable<Buffer>, contentType?: string, subdir?: string) => {
78
- const chunks: Buffer[] = [];
79
- for await (const chunk of stream) {
80
- chunks.push(Buffer.from(chunk));
81
- }
82
- const buffer = Buffer.concat(chunks);
83
- return {
84
- path: `/home/user/.klaw/media/${subdir ?? "unknown"}/saved-media`,
85
- contentType: detectMockContentType(buffer, contentType),
86
- size: buffer.length,
87
- };
88
- },
89
- );
90
- });
91
-
92
- it("persists inbound media with the shared media store", async () => {
93
- const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
94
- getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
95
-
96
- const result = await downloadLineMedia("mid-jpeg", "token");
97
-
98
- expect(saveMediaStreamMock).toHaveBeenCalledTimes(1);
99
- const call = saveMediaStreamCall();
100
- expect(call[1]).toBeUndefined();
101
- expect(call[2]).toBe("inbound");
102
- expect(call[3]).toBe(10 * 1024 * 1024);
103
- expect(result).toEqual({
104
- path: "/home/user/.klaw/media/inbound/saved-media",
105
- contentType: "image/jpeg",
106
- size: jpeg.length,
107
- });
108
- });
109
-
110
- it("does not pass the external messageId to saveMediaStream", async () => {
111
- const messageId = "a/../../../../etc/passwd";
112
- const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
113
- getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
114
-
115
- const result = await downloadLineMedia(messageId, "token");
116
-
117
- expect(result.size).toBe(jpeg.length);
118
- expect(result.contentType).toBe("image/jpeg");
119
- for (const arg of saveMediaStreamCall()) {
120
- if (typeof arg === "string") {
121
- expect(arg).not.toContain(messageId);
122
- }
123
- }
124
- });
125
-
126
- it("delegates oversized media rejection to saveMediaStream", async () => {
127
- getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.alloc(4), Buffer.alloc(4)]));
128
- saveMediaStreamMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
129
-
130
- await expect(downloadLineMedia("mid", "token", 7)).rejects.toThrow(/Media exceeds/i);
131
- expect(saveMediaStreamMock).toHaveBeenCalledTimes(1);
132
- });
133
-
134
- it("uses media store content type for M4A media", async () => {
135
- const m4aHeader = Buffer.from([
136
- 0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
137
- ]);
138
- getMessageContentMock.mockResolvedValueOnce(chunks([m4aHeader]));
139
-
140
- const result = await downloadLineMedia("mid-audio", "token");
141
-
142
- expect(result.contentType).toBe("audio/x-m4a");
143
- expect(saveMediaStreamCall()[2]).toBe("inbound");
144
- });
145
-
146
- it("uses media store content type for MP4 video", async () => {
147
- const mp4 = Buffer.from([
148
- 0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
149
- ]);
150
- getMessageContentMock.mockResolvedValueOnce(chunks([mp4]));
151
-
152
- const result = await downloadLineMedia("mid-mp4", "token");
153
-
154
- expect(result.contentType).toBe("video/mp4");
155
- });
156
-
157
- it("propagates media store failures", async () => {
158
- const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
159
- getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
160
- saveMediaStreamMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
161
-
162
- await expect(downloadLineMedia("mid-bad", "token")).rejects.toThrow(/Media exceeds/i);
163
- });
164
- });
package/src/download.ts DELETED
@@ -1,34 +0,0 @@
1
- import { messagingApi } from "@line/bot-sdk";
2
- import { saveMediaStream } from "klaw/plugin-sdk/media-store";
3
- import { logVerbose } from "klaw/plugin-sdk/runtime-env";
4
-
5
- interface DownloadResult {
6
- path: string;
7
- contentType?: string;
8
- size: number;
9
- }
10
-
11
- export async function downloadLineMedia(
12
- messageId: string,
13
- channelAccessToken: string,
14
- maxBytes = 10 * 1024 * 1024,
15
- ): Promise<DownloadResult> {
16
- const client = new messagingApi.MessagingApiBlobClient({
17
- channelAccessToken,
18
- });
19
-
20
- const response = await client.getMessageContent(messageId);
21
- const saved = await saveMediaStream(
22
- response as AsyncIterable<Buffer>,
23
- undefined,
24
- "inbound",
25
- maxBytes,
26
- );
27
- logVerbose(`line: persisted media ${messageId} to ${saved.path} (${saved.size} bytes)`);
28
-
29
- return {
30
- path: saved.path,
31
- contentType: saved.contentType,
32
- size: saved.size,
33
- };
34
- }