From 1da7906a5dcb6876d6e047d6e988f60432fb1271 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 02:26:41 +0000 Subject: [PATCH] fix(line): land #31151 M4A voice MIME detection (@scoootscooob) Landed from contributor PR #31151 by @scoootscooob. Co-authored-by: scoootscooob --- CHANGELOG.md | 1 + src/line/download.test.ts | 50 +++++++++++++++++++++++++++++++++++++++ src/line/download.ts | 19 +++++++++------ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47081ae8eac..a5a4e5aefd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/line/download.test.ts b/src/line/download.test.ts index 677f2049200..ea9a236867f 100644 --- a/src/line/download.test.ts +++ b/src/line/download.test.ts @@ -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$/); + }); }); diff --git a/src/line/download.ts b/src/line/download.ts index ceb6916a525..29f01258794 100644 --- a/src/line/download.ts +++ b/src/line/download.ts @@ -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"; } }