@openclaw/line 2026.5.2-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.
Files changed (86) hide show
  1. package/api.ts +11 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/contract-api.ts +5 -0
  4. package/index.ts +54 -0
  5. package/openclaw.plugin.json +342 -0
  6. package/package.json +58 -0
  7. package/runtime-api.ts +187 -0
  8. package/secret-contract-api.ts +4 -0
  9. package/setup-api.ts +2 -0
  10. package/setup-entry.ts +9 -0
  11. package/src/account-helpers.ts +16 -0
  12. package/src/accounts.test.ts +290 -0
  13. package/src/accounts.ts +187 -0
  14. package/src/actions.ts +61 -0
  15. package/src/auto-reply-delivery.test.ts +248 -0
  16. package/src/auto-reply-delivery.ts +200 -0
  17. package/src/bindings.ts +65 -0
  18. package/src/bot-access.ts +48 -0
  19. package/src/bot-handlers.test.ts +1089 -0
  20. package/src/bot-handlers.ts +642 -0
  21. package/src/bot-message-context.test.ts +405 -0
  22. package/src/bot-message-context.ts +581 -0
  23. package/src/bot.ts +70 -0
  24. package/src/card-command.ts +347 -0
  25. package/src/channel-access-token.ts +14 -0
  26. package/src/channel-api.ts +17 -0
  27. package/src/channel-setup-status.contract.test.ts +70 -0
  28. package/src/channel-shared.ts +48 -0
  29. package/src/channel.logout.test.ts +145 -0
  30. package/src/channel.runtime.ts +3 -0
  31. package/src/channel.sendPayload.test.ts +514 -0
  32. package/src/channel.setup.ts +11 -0
  33. package/src/channel.status.test.ts +63 -0
  34. package/src/channel.ts +154 -0
  35. package/src/config-adapter.ts +29 -0
  36. package/src/config-schema.ts +57 -0
  37. package/src/download.test.ts +133 -0
  38. package/src/download.ts +87 -0
  39. package/src/flex-templates/basic-cards.ts +395 -0
  40. package/src/flex-templates/common.ts +20 -0
  41. package/src/flex-templates/media-control-cards.ts +555 -0
  42. package/src/flex-templates/message.ts +13 -0
  43. package/src/flex-templates/schedule-cards.ts +467 -0
  44. package/src/flex-templates/types.ts +22 -0
  45. package/src/flex-templates.ts +32 -0
  46. package/src/gateway.ts +129 -0
  47. package/src/group-keys.test.ts +123 -0
  48. package/src/group-keys.ts +65 -0
  49. package/src/group-policy.ts +22 -0
  50. package/src/markdown-to-line.test.ts +348 -0
  51. package/src/markdown-to-line.ts +416 -0
  52. package/src/message-cards.test.ts +204 -0
  53. package/src/monitor.lifecycle.test.ts +421 -0
  54. package/src/monitor.runtime.ts +1 -0
  55. package/src/monitor.ts +506 -0
  56. package/src/outbound-media.test.ts +189 -0
  57. package/src/outbound-media.ts +120 -0
  58. package/src/outbound.runtime.ts +12 -0
  59. package/src/outbound.ts +356 -0
  60. package/src/probe.contract.test.ts +9 -0
  61. package/src/probe.runtime.ts +1 -0
  62. package/src/probe.ts +34 -0
  63. package/src/quick-reply-fallback.ts +10 -0
  64. package/src/reply-chunks.test.ts +179 -0
  65. package/src/reply-chunks.ts +110 -0
  66. package/src/reply-payload-transform.test.ts +387 -0
  67. package/src/reply-payload-transform.ts +317 -0
  68. package/src/rich-menu.test.ts +310 -0
  69. package/src/rich-menu.ts +326 -0
  70. package/src/runtime.ts +32 -0
  71. package/src/send.test.ts +346 -0
  72. package/src/send.ts +489 -0
  73. package/src/setup-core.ts +149 -0
  74. package/src/setup-runtime-api.ts +9 -0
  75. package/src/setup-surface.test.ts +474 -0
  76. package/src/setup-surface.ts +227 -0
  77. package/src/signature.test.ts +34 -0
  78. package/src/signature.ts +24 -0
  79. package/src/status.ts +37 -0
  80. package/src/template-messages.ts +333 -0
  81. package/src/types.ts +128 -0
  82. package/src/webhook-node.test.ts +513 -0
  83. package/src/webhook-node.ts +131 -0
  84. package/src/webhook-utils.ts +10 -0
  85. package/src/webhook.ts +111 -0
  86. package/tsconfig.json +16 -0
package/src/channel.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
2
+ import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
3
+ import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
4
+ import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
5
+ import { createLazyRuntimeModule } from "openclaw/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 { 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: "openclaw 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
+ bindings: lineBindingsAdapter,
76
+ conversationBindings: {
77
+ defaultTopLevelPlacement: "current",
78
+ },
79
+ agentPrompt: {
80
+ messageToolHints: () => [
81
+ "",
82
+ "### LINE Rich Messages",
83
+ "LINE supports rich visual messages. Use these directives in your reply when appropriate:",
84
+ "",
85
+ "**Quick Replies** (bottom button suggestions):",
86
+ " [[quick_replies: Option 1, Option 2, Option 3]]",
87
+ "",
88
+ "**Location** (map pin):",
89
+ " [[location: Place Name | Address | latitude | longitude]]",
90
+ "",
91
+ "**Confirm Dialog** (yes/no prompt):",
92
+ " [[confirm: Question text? | Yes Label | No Label]]",
93
+ "",
94
+ "**Button Menu** (title + text + buttons):",
95
+ " [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]",
96
+ "",
97
+ "**Media Player Card** (music status):",
98
+ " [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]",
99
+ " - Status: 'playing' or 'paused' (optional)",
100
+ "",
101
+ "**Event Card** (calendar events, meetings):",
102
+ " [[event: Event Title | Date | Time | Location | Description]]",
103
+ " - Time, Location, Description are optional",
104
+ "",
105
+ "**Agenda Card** (multiple events/schedule):",
106
+ " [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]",
107
+ "",
108
+ "**Device Control Card** (smart devices, TVs, etc.):",
109
+ " [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]",
110
+ "",
111
+ "**Apple TV Remote** (full D-pad + transport):",
112
+ " [[appletv_remote: Apple TV | Playing]]",
113
+ "",
114
+ "**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.",
115
+ "",
116
+ "When to use rich messages:",
117
+ "- Use [[quick_replies:...]] when offering 2-4 clear options",
118
+ "- Use [[confirm:...]] for yes/no decisions",
119
+ "- Use [[buttons:...]] for menus with actions/links",
120
+ "- Use [[location:...]] when sharing a place",
121
+ "- Use [[media_player:...]] when showing what's playing",
122
+ "- Use [[event:...]] for calendar event details",
123
+ "- Use [[agenda:...]] for a day's schedule or event list",
124
+ "- Use [[device:...]] for smart device status/controls",
125
+ "- Tables/code in your response auto-convert to visual cards",
126
+ ],
127
+ },
128
+ },
129
+ pairing: {
130
+ text: {
131
+ idLabel: "lineUserId",
132
+ message: "OpenClaw: your access has been approved.",
133
+ normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i),
134
+ notify: async ({ cfg, id, message }) => {
135
+ const account = (getLineRuntime().channel.line?.resolveLineAccount ?? resolveLineAccount)({
136
+ cfg,
137
+ });
138
+ if (!account.channelAccessToken) {
139
+ throw new Error("LINE channel access token not configured");
140
+ }
141
+ const pushMessageLine =
142
+ getLineRuntime().channel.line?.pushMessageLine ??
143
+ (await loadLineChannelRuntime()).pushMessageLine;
144
+ await pushMessageLine(id, message, {
145
+ cfg,
146
+ accountId: account.accountId,
147
+ channelAccessToken: account.channelAccessToken,
148
+ });
149
+ },
150
+ },
151
+ },
152
+ security: lineSecurityAdapter,
153
+ outbound: lineOutboundAdapter,
154
+ });
@@ -0,0 +1,29 @@
1
+ import { createScopedChannelConfigAdapter } from "openclaw/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
+ });
@@ -0,0 +1,57 @@
1
+ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
2
+ import { z } from "openclaw/plugin-sdk/zod";
3
+
4
+ const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]);
5
+ const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
6
+ const ThreadBindingsSchema = z
7
+ .object({
8
+ enabled: z.boolean().optional(),
9
+ idleHours: z.number().optional(),
10
+ maxAgeHours: z.number().optional(),
11
+ spawnSessions: z.boolean().optional(),
12
+ defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
13
+ spawnSubagentSessions: z.boolean().optional(),
14
+ spawnAcpSessions: z.boolean().optional(),
15
+ })
16
+ .strict();
17
+
18
+ const LineCommonConfigSchema = z.object({
19
+ enabled: z.boolean().optional(),
20
+ channelAccessToken: z.string().optional(),
21
+ channelSecret: z.string().optional(),
22
+ tokenFile: z.string().optional(),
23
+ secretFile: z.string().optional(),
24
+ name: z.string().optional(),
25
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
26
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
27
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
28
+ groupPolicy: GroupPolicySchema.optional().default("allowlist"),
29
+ responsePrefix: z.string().optional(),
30
+ mediaMaxMb: z.number().optional(),
31
+ webhookPath: z.string().optional(),
32
+ threadBindings: ThreadBindingsSchema.optional(),
33
+ });
34
+
35
+ const LineGroupConfigSchema = z
36
+ .object({
37
+ enabled: z.boolean().optional(),
38
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
39
+ requireMention: z.boolean().optional(),
40
+ systemPrompt: z.string().optional(),
41
+ skills: z.array(z.string()).optional(),
42
+ })
43
+ .strict();
44
+
45
+ const LineAccountConfigSchema = LineCommonConfigSchema.extend({
46
+ groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
47
+ }).strict();
48
+
49
+ export const LineConfigSchema = LineCommonConfigSchema.extend({
50
+ accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),
51
+ defaultAccount: z.string().optional(),
52
+ groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
53
+ }).strict();
54
+
55
+ export const LineChannelConfigSchema = buildChannelConfigSchema(LineConfigSchema);
56
+
57
+ export type LineConfigSchemaType = z.infer<typeof LineConfigSchema>;
@@ -0,0 +1,133 @@
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const getMessageContentMock = vi.hoisted(() => vi.fn());
4
+ const saveMediaBufferMock = 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("openclaw/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("openclaw/plugin-sdk/media-store", () => ({
31
+ saveMediaBuffer: saveMediaBufferMock,
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
+ describe("downloadLineMedia", () => {
43
+ beforeAll(async () => {
44
+ ({ downloadLineMedia } = await import("./download.js"));
45
+ });
46
+
47
+ beforeEach(() => {
48
+ vi.restoreAllMocks();
49
+ getMessageContentMock.mockReset();
50
+ saveMediaBufferMock.mockReset();
51
+ saveMediaBufferMock.mockImplementation(
52
+ async (_buffer: Buffer, contentType?: string, subdir?: string) => ({
53
+ path: `/home/user/.openclaw/media/${subdir ?? "unknown"}/saved-media`,
54
+ contentType,
55
+ }),
56
+ );
57
+ });
58
+
59
+ it("persists inbound media with the shared media store", async () => {
60
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
61
+ getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
62
+
63
+ const result = await downloadLineMedia("mid-jpeg", "token");
64
+
65
+ expect(saveMediaBufferMock).toHaveBeenCalledTimes(1);
66
+ const call = saveMediaBufferMock.mock.calls[0];
67
+ expect((call?.[0] as Buffer).equals(jpeg)).toBe(true);
68
+ expect(call?.[1]).toBe("image/jpeg");
69
+ expect(call?.[2]).toBe("inbound");
70
+ expect(call?.[3]).toBe(10 * 1024 * 1024);
71
+ expect(result).toEqual({
72
+ path: "/home/user/.openclaw/media/inbound/saved-media",
73
+ contentType: "image/jpeg",
74
+ size: jpeg.length,
75
+ });
76
+ });
77
+
78
+ it("does not pass the external messageId to saveMediaBuffer", async () => {
79
+ const messageId = "a/../../../../etc/passwd";
80
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
81
+ getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
82
+
83
+ const result = await downloadLineMedia(messageId, "token");
84
+
85
+ expect(result.size).toBe(jpeg.length);
86
+ expect(result.contentType).toBe("image/jpeg");
87
+ for (const arg of saveMediaBufferMock.mock.calls[0] ?? []) {
88
+ if (typeof arg === "string") {
89
+ expect(arg).not.toContain(messageId);
90
+ }
91
+ }
92
+ });
93
+
94
+ it("rejects oversized media before invoking saveMediaBuffer", async () => {
95
+ getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.alloc(4), Buffer.alloc(4)]));
96
+
97
+ await expect(downloadLineMedia("mid", "token", 7)).rejects.toThrow(/Media exceeds/i);
98
+ expect(saveMediaBufferMock).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("classifies M4A ftyp major brand as audio/mp4", async () => {
102
+ const m4aHeader = Buffer.from([
103
+ 0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
104
+ ]);
105
+ getMessageContentMock.mockResolvedValueOnce(chunks([m4aHeader]));
106
+
107
+ const result = await downloadLineMedia("mid-audio", "token");
108
+
109
+ expect(result.contentType).toBe("audio/mp4");
110
+ expect(saveMediaBufferMock.mock.calls[0]?.[1]).toBe("audio/mp4");
111
+ expect(saveMediaBufferMock.mock.calls[0]?.[2]).toBe("inbound");
112
+ });
113
+
114
+ it("detects MP4 video from ftyp major brand (isom)", async () => {
115
+ const mp4 = Buffer.from([
116
+ 0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
117
+ ]);
118
+ getMessageContentMock.mockResolvedValueOnce(chunks([mp4]));
119
+
120
+ const result = await downloadLineMedia("mid-mp4", "token");
121
+
122
+ expect(result.contentType).toBe("video/mp4");
123
+ expect(saveMediaBufferMock.mock.calls[0]?.[1]).toBe("video/mp4");
124
+ });
125
+
126
+ it("propagates media store failures", async () => {
127
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0x00]);
128
+ getMessageContentMock.mockResolvedValueOnce(chunks([jpeg]));
129
+ saveMediaBufferMock.mockRejectedValueOnce(new Error("Media exceeds 0MB limit"));
130
+
131
+ await expect(downloadLineMedia("mid-bad", "token")).rejects.toThrow(/Media exceeds/i);
132
+ });
133
+ });
@@ -0,0 +1,87 @@
1
+ import { messagingApi } from "@line/bot-sdk";
2
+ import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
3
+ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
4
+ import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/text-runtime";
5
+
6
+ interface DownloadResult {
7
+ path: string;
8
+ contentType?: string;
9
+ size: number;
10
+ }
11
+
12
+ const AUDIO_BRANDS = new Set(["m4a ", "m4b ", "m4p ", "m4r ", "f4a ", "f4b "]);
13
+
14
+ export async function downloadLineMedia(
15
+ messageId: string,
16
+ channelAccessToken: string,
17
+ maxBytes = 10 * 1024 * 1024,
18
+ ): Promise<DownloadResult> {
19
+ const client = new messagingApi.MessagingApiBlobClient({
20
+ channelAccessToken,
21
+ });
22
+
23
+ const response = await client.getMessageContent(messageId);
24
+ const chunks: Buffer[] = [];
25
+ let totalSize = 0;
26
+
27
+ for await (const chunk of response as AsyncIterable<Buffer>) {
28
+ totalSize += chunk.length;
29
+ if (totalSize > maxBytes) {
30
+ throw new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
31
+ }
32
+ chunks.push(chunk);
33
+ }
34
+
35
+ const buffer = Buffer.concat(chunks);
36
+ const contentType = detectContentType(buffer);
37
+ const saved = await saveMediaBuffer(buffer, contentType, "inbound", maxBytes);
38
+ logVerbose(`line: persisted media ${messageId} to ${saved.path} (${buffer.length} bytes)`);
39
+
40
+ return {
41
+ path: saved.path,
42
+ contentType: saved.contentType,
43
+ size: buffer.length,
44
+ };
45
+ }
46
+
47
+ function detectContentType(buffer: Buffer): string {
48
+ const hasFtypBox =
49
+ buffer.length >= 12 &&
50
+ buffer[4] === 0x66 &&
51
+ buffer[5] === 0x74 &&
52
+ buffer[6] === 0x79 &&
53
+ buffer[7] === 0x70;
54
+
55
+ if (buffer.length >= 2) {
56
+ if (buffer[0] === 0xff && buffer[1] === 0xd8) {
57
+ return "image/jpeg";
58
+ }
59
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
60
+ return "image/png";
61
+ }
62
+ if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
63
+ return "image/gif";
64
+ }
65
+ if (
66
+ buffer[0] === 0x52 &&
67
+ buffer[1] === 0x49 &&
68
+ buffer[2] === 0x46 &&
69
+ buffer[3] === 0x46 &&
70
+ buffer[8] === 0x57 &&
71
+ buffer[9] === 0x45 &&
72
+ buffer[10] === 0x42 &&
73
+ buffer[11] === 0x50
74
+ ) {
75
+ return "image/webp";
76
+ }
77
+ if (hasFtypBox) {
78
+ const majorBrand = lowercasePreservingWhitespace(buffer.toString("ascii", 8, 12));
79
+ if (AUDIO_BRANDS.has(majorBrand)) {
80
+ return "audio/mp4";
81
+ }
82
+ return "video/mp4";
83
+ }
84
+ }
85
+
86
+ return "application/octet-stream";
87
+ }