Skip to content

Commit aa5ec51

Browse files
authored
fix(line): preserve uploaded file names for media detection (#96403)
* fix(line): preserve file upload filenames * test(line): fail unexpected media downloads
1 parent 2cc43ae commit aa5ec51

4 files changed

Lines changed: 71 additions & 5 deletions

File tree

extensions/line/src/bot-handlers.test.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ const { readAllowFromStoreMock, upsertPairingRequestMock } = vi.hoisted(() => ({
125125
readAllowFromStoreMock: vi.fn(async () => [] as string[]),
126126
upsertPairingRequestMock: vi.fn(async (_args: unknown) => ({ code: "CODE", created: true })),
127127
}));
128+
const downloadLineMediaMock = vi.hoisted(() => vi.fn());
128129

129130
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
130131
resolvePairingIdLabel: () => "lineUserId",
@@ -133,9 +134,7 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
133134
}));
134135

135136
vi.mock("./download.js", () => ({
136-
downloadLineMedia: async () => {
137-
throw new Error("downloadLineMedia should not be called from bot-handlers tests");
138-
},
137+
downloadLineMedia: downloadLineMediaMock,
139138
}));
140139

141140
vi.mock("./send.js", () => ({
@@ -350,6 +349,10 @@ describe("handleLineWebhookEvents", () => {
350349
readAllowFromStoreMock.mockImplementation(async () => [] as string[]);
351350
upsertPairingRequestMock.mockReset();
352351
upsertPairingRequestMock.mockImplementation(async () => ({ code: "CODE", created: true }));
352+
downloadLineMediaMock.mockReset();
353+
downloadLineMediaMock.mockImplementation(async () => {
354+
throw new Error("downloadLineMedia should not be called from bot-handlers tests");
355+
});
353356
});
354357
it("blocks group messages when groupPolicy is disabled", async () => {
355358
const processMessage = vi.fn();
@@ -1006,6 +1009,48 @@ describe("handleLineWebhookEvents", () => {
10061009
expect(processMessage).toHaveBeenCalledTimes(1);
10071010
});
10081011

1012+
it("forwards LINE file names to media downloads", async () => {
1013+
const processMessage = vi.fn();
1014+
downloadLineMediaMock.mockResolvedValueOnce({
1015+
path: "/tmp/line-media/voice-note.m4a",
1016+
contentType: "audio/x-m4a",
1017+
size: 1234,
1018+
});
1019+
const event = createTestMessageEvent({
1020+
message: {
1021+
id: "file-audio-1",
1022+
type: "file",
1023+
fileName: "voice-note.m4a",
1024+
fileSize: 4096,
1025+
} as MessageEvent["message"],
1026+
source: { type: "user", userId: "user-file-audio" },
1027+
webhookEventId: "evt-file-audio",
1028+
});
1029+
1030+
await handleLineWebhookEvents(
1031+
[event],
1032+
createLineWebhookTestContext({
1033+
processMessage,
1034+
dmPolicy: "open",
1035+
}),
1036+
);
1037+
1038+
expect(downloadLineMediaMock).toHaveBeenCalledWith("file-audio-1", "token", 1, {
1039+
originalFilename: "voice-note.m4a",
1040+
});
1041+
expect(buildLineMessageContextMock).toHaveBeenCalledWith(
1042+
expect.objectContaining({
1043+
allMedia: [
1044+
{
1045+
path: "/tmp/line-media/voice-note.m4a",
1046+
contentType: "audio/x-m4a",
1047+
},
1048+
],
1049+
}),
1050+
);
1051+
expect(processMessage).toHaveBeenCalledTimes(1);
1052+
});
1053+
10091054
it("allows non-text group messages through when requireMention is set (cannot detect mention)", async () => {
10101055
// Image message -- LINE only carries mention metadata on text messages.
10111056
const event = createTestMessageEvent({

extensions/line/src/bot-handlers.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import {
2424
resolveDefaultGroupPolicy,
2525
warnMissingProviderGroupPolicyFallbackOnce,
2626
} from "openclaw/plugin-sdk/runtime-group-policy";
27-
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
27+
import {
28+
normalizeOptionalString,
29+
normalizeStringEntries,
30+
} from "openclaw/plugin-sdk/string-coerce-runtime";
2831
import { firstDefined, normalizeLineAllowEntry } from "./bot-access.js";
2932
import {
3033
buildLineMessageContext,
@@ -467,7 +470,11 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte
467470

468471
if (isDownloadableLineMessageType(message.type)) {
469472
try {
470-
const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes);
473+
const originalFilename =
474+
message.type === "file" ? normalizeOptionalString(message.fileName) : undefined;
475+
const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes, {
476+
originalFilename,
477+
});
471478
allMedia.push({
472479
path: media.path,
473480
contentType: media.contentType,

extensions/line/src/download.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,18 @@ describe("downloadLineMedia", () => {
144144
expect(saveMediaStreamCall()[2]).toBe("inbound");
145145
});
146146

147+
it("passes original filenames to the media store for extension fallback", async () => {
148+
getMessageContentMock.mockResolvedValueOnce(chunks([Buffer.from("unknown-audio-bytes")]));
149+
150+
await downloadLineMedia("mid-file-audio", "token", 10 * 1024 * 1024, {
151+
originalFilename: "voice-note.m4a",
152+
});
153+
154+
const call = saveMediaStreamCall();
155+
expect(call[3]).toBe(10 * 1024 * 1024);
156+
expect(call[4]).toBe("voice-note.m4a");
157+
});
158+
147159
it("uses media store content type for MP4 video", async () => {
148160
const mp4 = Buffer.from([
149161
0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,

extensions/line/src/download.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export async function downloadLineMedia(
1313
messageId: string,
1414
channelAccessToken: string,
1515
maxBytes = 10 * 1024 * 1024,
16+
options?: { originalFilename?: string },
1617
): Promise<DownloadResult> {
1718
const client = new messagingApi.MessagingApiBlobClient({
1819
channelAccessToken,
@@ -24,6 +25,7 @@ export async function downloadLineMedia(
2425
undefined,
2526
"inbound",
2627
maxBytes,
28+
options?.originalFilename,
2729
);
2830
logVerbose(`line: persisted media ${messageId} to ${saved.path} (${saved.size} bytes)`);
2931

0 commit comments

Comments
 (0)