From 6ebf503fa8d21aa43f1b63f1c6a4eacf1a15f070 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 03:17:02 +0100 Subject: [PATCH] refactor(media): centralize voice compatibility policy --- .../matrix/src/matrix/send/formatting.ts | 16 ++++++---- src/media/audio.test.ts | 16 +++++----- src/media/audio.ts | 30 ++++++++++++------- src/media/mime.test.ts | 20 ++++++++++++- src/media/mime.ts | 9 +++--- src/telegram/voice.ts | 11 ++----- 6 files changed, 62 insertions(+), 40 deletions(-) diff --git a/extensions/matrix/src/matrix/send/formatting.ts b/extensions/matrix/src/matrix/send/formatting.ts index 3189d1e90..bf0ed1989 100644 --- a/extensions/matrix/src/matrix/send/formatting.ts +++ b/extensions/matrix/src/matrix/send/formatting.ts @@ -77,13 +77,17 @@ export function resolveMatrixVoiceDecision(opts: { if (!opts.wantsVoice) { return { useVoice: false }; } - if ( - getCore().media.isVoiceCompatibleAudio({ - contentType: opts.contentType, - fileName: opts.fileName, - }) - ) { + if (isMatrixVoiceCompatibleAudio(opts)) { return { useVoice: true }; } return { useVoice: false }; } + +function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean { + // Matrix currently shares the core voice compatibility policy. + // Keep this wrapper as the seam if Matrix policy diverges later. + return getCore().media.isVoiceCompatibleAudio({ + contentType: opts.contentType, + fileName: opts.fileName, + }); +} diff --git a/src/media/audio.test.ts b/src/media/audio.test.ts index af25bb69d..c559f65f9 100644 --- a/src/media/audio.test.ts +++ b/src/media/audio.test.ts @@ -1,22 +1,20 @@ import { describe, expect, it } from "vitest"; -import { isVoiceCompatibleAudio } from "./audio.js"; +import { + isVoiceCompatibleAudio, + TELEGRAM_VOICE_AUDIO_EXTENSIONS, + TELEGRAM_VOICE_MIME_TYPES, +} from "./audio.js"; describe("isVoiceCompatibleAudio", () => { it.each([ - { contentType: "audio/ogg", fileName: null }, - { contentType: "audio/opus", fileName: null }, + ...Array.from(TELEGRAM_VOICE_MIME_TYPES, (contentType) => ({ contentType, fileName: null })), { contentType: "audio/ogg; codecs=opus", fileName: null }, - { contentType: "audio/mpeg", fileName: null }, - { contentType: "audio/mp3", fileName: null }, - { contentType: "audio/mp4", fileName: null }, { contentType: "audio/mp4; codecs=mp4a.40.2", fileName: null }, - { contentType: "audio/x-m4a", fileName: null }, - { contentType: "audio/m4a", fileName: null }, ])("returns true for MIME type $contentType", (opts) => { expect(isVoiceCompatibleAudio(opts)).toBe(true); }); - it.each([".ogg", ".oga", ".opus", ".mp3", ".m4a"])("returns true for extension %s", (ext) => { + it.each(Array.from(TELEGRAM_VOICE_AUDIO_EXTENSIONS))("returns true for extension %s", (ext) => { expect(isVoiceCompatibleAudio({ fileName: `voice${ext}` })).toBe(true); }); diff --git a/src/media/audio.ts b/src/media/audio.ts index b632533bb..1bfb5b8a8 100644 --- a/src/media/audio.ts +++ b/src/media/audio.ts @@ -1,13 +1,13 @@ -import { getFileExtension } from "./mime.js"; +import { getFileExtension, normalizeMimeType } from "./mime.js"; -const VOICE_AUDIO_EXTENSIONS = new Set([".oga", ".ogg", ".opus", ".mp3", ".m4a"]); +export const TELEGRAM_VOICE_AUDIO_EXTENSIONS = new Set([".oga", ".ogg", ".opus", ".mp3", ".m4a"]); /** * MIME types compatible with voice messages. * Telegram sendVoice supports OGG/Opus, MP3, and M4A. * https://core.telegram.org/bots/api#sendvoice */ -const VOICE_MIME_TYPES = new Set([ +export const TELEGRAM_VOICE_MIME_TYPES = new Set([ "audio/ogg", "audio/opus", "audio/mpeg", @@ -17,16 +17,13 @@ const VOICE_MIME_TYPES = new Set([ "audio/m4a", ]); -export function isVoiceCompatibleAudio(opts: { +export function isTelegramVoiceCompatibleAudio(opts: { contentType?: string | null; fileName?: string | null; }): boolean { - const mime = opts.contentType?.toLowerCase().trim(); - if (mime) { - const baseMime = mime.split(";")[0].trim(); - if (VOICE_MIME_TYPES.has(baseMime)) { - return true; - } + const mime = normalizeMimeType(opts.contentType); + if (mime && TELEGRAM_VOICE_MIME_TYPES.has(mime)) { + return true; } const fileName = opts.fileName?.trim(); if (!fileName) { @@ -36,5 +33,16 @@ export function isVoiceCompatibleAudio(opts: { if (!ext) { return false; } - return VOICE_AUDIO_EXTENSIONS.has(ext); + return TELEGRAM_VOICE_AUDIO_EXTENSIONS.has(ext); +} + +/** + * Backward-compatible alias used across plugin/runtime call sites. + * Keeps existing behavior while making Telegram-specific policy explicit. + */ +export function isVoiceCompatibleAudio(opts: { + contentType?: string | null; + fileName?: string | null; +}): boolean { + return isTelegramVoiceCompatibleAudio(opts); } diff --git a/src/media/mime.test.ts b/src/media/mime.test.ts index 9798e1f5e..d15448897 100644 --- a/src/media/mime.test.ts +++ b/src/media/mime.test.ts @@ -1,6 +1,12 @@ import JSZip from "jszip"; import { describe, expect, it } from "vitest"; -import { detectMime, extensionForMime, imageMimeFromFormat, isAudioFileName } from "./mime.js"; +import { + detectMime, + extensionForMime, + imageMimeFromFormat, + isAudioFileName, + normalizeMimeType, +} from "./mime.js"; async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise { const zip = new JSZip(); @@ -110,3 +116,15 @@ describe("isAudioFileName", () => { } }); }); + +describe("normalizeMimeType", () => { + it("normalizes case and strips parameters", () => { + expect(normalizeMimeType("Audio/MP4; codecs=mp4a.40.2")).toBe("audio/mp4"); + }); + + it("returns undefined for empty input", () => { + expect(normalizeMimeType(" ")).toBeUndefined(); + expect(normalizeMimeType(null)).toBeUndefined(); + expect(normalizeMimeType(undefined)).toBeUndefined(); + }); +}); diff --git a/src/media/mime.ts b/src/media/mime.ts index 7d0296d23..f3e89df0d 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -52,7 +52,7 @@ const AUDIO_FILE_EXTENSIONS = new Set([ ".wav", ]); -function normalizeHeaderMime(mime?: string | null): string | undefined { +export function normalizeMimeType(mime?: string | null): string | undefined { if (!mime) { return undefined; } @@ -120,7 +120,7 @@ async function detectMimeImpl(opts: { const ext = getFileExtension(opts.filePath); const extMime = ext ? MIME_BY_EXT[ext] : undefined; - const headerMime = normalizeHeaderMime(opts.headerMime); + const headerMime = normalizeMimeType(opts.headerMime); const sniffed = await sniffMime(opts.buffer); // Prefer sniffed types, but don't let generic container types override a more @@ -145,10 +145,11 @@ async function detectMimeImpl(opts: { } export function extensionForMime(mime?: string | null): string | undefined { - if (!mime) { + const normalized = normalizeMimeType(mime); + if (!normalized) { return undefined; } - return EXT_BY_MIME[mime.toLowerCase()]; + return EXT_BY_MIME[normalized]; } export function isGifMedia(opts: { diff --git a/src/telegram/voice.ts b/src/telegram/voice.ts index 39da4e500..67d8bc56e 100644 --- a/src/telegram/voice.ts +++ b/src/telegram/voice.ts @@ -1,11 +1,4 @@ -import { isVoiceCompatibleAudio } from "../media/audio.js"; - -export function isTelegramVoiceCompatible(opts: { - contentType?: string | null; - fileName?: string | null; -}): boolean { - return isVoiceCompatibleAudio(opts); -} +import { isTelegramVoiceCompatibleAudio } from "../media/audio.js"; export function resolveTelegramVoiceDecision(opts: { wantsVoice: boolean; @@ -15,7 +8,7 @@ export function resolveTelegramVoiceDecision(opts: { if (!opts.wantsVoice) { return { useVoice: false }; } - if (isTelegramVoiceCompatible(opts)) { + if (isTelegramVoiceCompatibleAudio(opts)) { return { useVoice: true }; } const contentType = opts.contentType ?? "unknown";