fix(media): recognize MP3 and M4A as voice-compatible audio (#15438)

* fix(media): recognize MP3 and M4A as voice-compatible audio

Telegram sendVoice supports OGG/Opus, MP3, and M4A, but
isVoiceCompatibleAudio only recognized OGG/Opus formats.

- Add MP3 and M4A extensions and MIME types
- Use explicit MIME set instead of substring matching
- Handle MIME parameters (e.g. 'audio/ogg; codecs=opus')
- Add test coverage for all supported and unsupported formats

* fix: narrow MIME allowlist per review feedback

Remove audio/mp4 and audio/aac from voice MIME types — too broad.
Keep only M4A-specific types (audio/x-m4a, audio/m4a).
Add audio/mp4 and audio/aac as negative test cases.

* fix: align voice compatibility and channel coverage (#15438) (thanks @azade-c)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Azade 🐐
2026-02-14 02:03:02 +00:00
committed by GitHub
parent 0b8227fa92
commit 1b95220a99
6 changed files with 187 additions and 11 deletions

View File

@@ -24,6 +24,8 @@ const loadWebMediaMock = vi.fn().mockResolvedValue({
contentType: "image/png",
kind: "image",
});
const mediaKindFromMimeMock = vi.fn(() => "image");
const isVoiceCompatibleAudioMock = vi.fn(() => false);
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
const resizeToJpegMock = vi.fn();
@@ -33,8 +35,8 @@ const runtimeStub = {
},
media: {
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
mediaKindFromMime: () => "image",
isVoiceCompatibleAudio: () => false,
mediaKindFromMime: (...args: unknown[]) => mediaKindFromMimeMock(...args),
isVoiceCompatibleAudio: (...args: unknown[]) => isVoiceCompatibleAudioMock(...args),
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
},
@@ -71,6 +73,8 @@ describe("sendMessageMatrix media", () => {
beforeEach(() => {
vi.clearAllMocks();
mediaKindFromMimeMock.mockReturnValue("image");
isVoiceCompatibleAudioMock.mockReturnValue(false);
setMatrixRuntime(runtimeStub);
});
@@ -133,6 +137,66 @@ describe("sendMessageMatrix media", () => {
expect(content.url).toBeUndefined();
expect(content.file?.url).toBe("mxc://example/file");
});
it("marks voice metadata and sends caption follow-up when audioAsVoice is compatible", async () => {
const { client, sendMessage } = makeClient();
mediaKindFromMimeMock.mockReturnValue("audio");
isVoiceCompatibleAudioMock.mockReturnValue(true);
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("audio"),
fileName: "clip.mp3",
contentType: "audio/mpeg",
kind: "audio",
});
await sendMessageMatrix("room:!room:example", "voice caption", {
client,
mediaUrl: "file:///tmp/clip.mp3",
audioAsVoice: true,
});
expect(isVoiceCompatibleAudioMock).toHaveBeenCalledWith({
contentType: "audio/mpeg",
fileName: "clip.mp3",
});
expect(sendMessage).toHaveBeenCalledTimes(2);
const mediaContent = sendMessage.mock.calls[0]?.[1] as {
msgtype?: string;
body?: string;
"org.matrix.msc3245.voice"?: Record<string, never>;
};
expect(mediaContent.msgtype).toBe("m.audio");
expect(mediaContent.body).toBe("Voice message");
expect(mediaContent["org.matrix.msc3245.voice"]).toEqual({});
});
it("keeps regular audio payload when audioAsVoice media is incompatible", async () => {
const { client, sendMessage } = makeClient();
mediaKindFromMimeMock.mockReturnValue("audio");
isVoiceCompatibleAudioMock.mockReturnValue(false);
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("audio"),
fileName: "clip.wav",
contentType: "audio/wav",
kind: "audio",
});
await sendMessageMatrix("room:!room:example", "voice caption", {
client,
mediaUrl: "file:///tmp/clip.wav",
audioAsVoice: true,
});
expect(sendMessage).toHaveBeenCalledTimes(1);
const mediaContent = sendMessage.mock.calls[0]?.[1] as {
msgtype?: string;
body?: string;
"org.matrix.msc3245.voice"?: Record<string, never>;
};
expect(mediaContent.msgtype).toBe("m.audio");
expect(mediaContent.body).toBe("voice caption");
expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined();
});
});
describe("sendMessageMatrix threads", () => {