import logging import os from pathlib import Path from yuxi.channel.extensions.bluebubbles.client import BlueBubblesClient from yuxi.channel.extensions.bluebubbles.outbound import send_attachment logger = logging.getLogger(__name__) SUPPORTED_AUDIO_FORMATS = frozenset({"caf", "mp3", "m4a", "wav", "aac"}) PREFERRED_FORMAT = "caf" MAX_VOICE_MEMO_MB = 5.0 VOICE_MEMO_MIME = "audio/x-caf" def _audio_file_ext(file_path: str) -> str: return Path(file_path).suffix.lower().lstrip(".") def _audio_file_size_mb(file_path: str) -> float: try: return os.path.getsize(file_path) / (1024 * 1024) except OSError: return 0.0 def validate_voice_input(file_path: str) -> None: if not os.path.isfile(file_path): raise FileNotFoundError(f"Voice memo file not found: {file_path}") ext = _audio_file_ext(file_path) if ext not in SUPPORTED_AUDIO_FORMATS: raise ValueError(f"Unsupported audio format '.{ext}'. Supported: {', '.join(sorted(SUPPORTED_AUDIO_FORMATS))}") size_mb = _audio_file_size_mb(file_path) if size_mb > MAX_VOICE_MEMO_MB: raise ValueError(f"Voice memo too large: {size_mb:.1f}MB (max {MAX_VOICE_MEMO_MB}MB)") if ext != PREFERRED_FORMAT: logger.warning( "Voice memo format is '.%s', prefer '.%s' to avoid BlueBubbles Server CAF->MP3 race condition", ext, PREFERRED_FORMAT, ) async def send_voice_memo( client: BlueBubblesClient, chat_guid: str, audio_file_path: str, *, private_api_available: bool = False, ) -> dict: validate_voice_input(audio_file_path) ext = _audio_file_ext(audio_file_path) mime_type = "audio/x-caf" if ext == "caf" else f"audio/{ext}" logger.info( "Sending iMessage voice memo: chat=%s format=%s size=%.1fMB", chat_guid, ext, _audio_file_size_mb(audio_file_path), ) return await send_attachment( client, chat_guid, audio_file_path, mime_type=mime_type, as_voice=True, private_api_available=private_api_available, )