fix(line): land #31151 M4A voice MIME detection (@scoootscooob)

Landed from contributor PR #31151 by @scoootscooob.

Co-authored-by: scoootscooob <scoootscooob@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-03-02 02:26:41 +00:00
parent a1a8ec6870
commit 1da7906a5d
3 changed files with 63 additions and 7 deletions

View File

@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.

View File

@ -66,4 +66,54 @@ describe("downloadLineMedia", () => {
await expect(downloadLineMedia("mid", "token", 7)).rejects.toThrow(/Media exceeds/i);
expect(writeSpy).not.toHaveBeenCalled();
});
it("detects M4A audio from ftyp major brand (#29751)", async () => {
// Real M4A magic bytes: size(4) + "ftyp" + "M4A "
const m4a = Buffer.from([
0x00,
0x00,
0x00,
0x1c, // box size
0x66,
0x74,
0x79,
0x70, // "ftyp"
0x4d,
0x34,
0x41,
0x20, // "M4A " major brand
]);
getMessageContentMock.mockResolvedValueOnce(chunks([m4a]));
vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
const result = await downloadLineMedia("mid-m4a", "token");
expect(result.contentType).toBe("audio/mp4");
expect(result.path).toMatch(/\.m4a$/);
});
it("detects MP4 video from ftyp major brand (isom)", async () => {
// MP4 video magic bytes: size(4) + "ftyp" + "isom"
const mp4 = Buffer.from([
0x00,
0x00,
0x00,
0x1c,
0x66,
0x74,
0x79,
0x70,
0x69,
0x73,
0x6f,
0x6d, // "isom" major brand
]);
getMessageContentMock.mockResolvedValueOnce(chunks([mp4]));
vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
const result = await downloadLineMedia("mid-mp4", "token");
expect(result.contentType).toBe("video/mp4");
expect(result.path).toMatch(/\.mp4$/);
});
});

View File

@ -80,15 +80,20 @@ function detectContentType(buffer: Buffer): string {
) {
return "image/webp";
}
// MP4
if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
return "video/mp4";
}
// M4A/AAC
if (buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0x00) {
if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
// MPEG-4 container (ftyp box) — distinguish audio (M4A) from video (MP4)
// by checking the major brand at bytes 8-11.
if (
buffer.length >= 12 &&
buffer[4] === 0x66 &&
buffer[5] === 0x74 &&
buffer[6] === 0x79 &&
buffer[7] === 0x70
) {
const brand = String.fromCharCode(buffer[8], buffer[9], buffer[10], buffer[11]);
if (brand === "M4A " || brand === "M4B ") {
return "audio/mp4";
}
return "video/mp4";
}
}