新增BlueBubbles适配器全套核心工具类与服务,包含会话管理、消息去重、Webhook验证、缓存系统、账号配置解析、聊天消息处理等完整功能模块,支持iMessage消息收发、群管理、反应特效、语音合成等能力,提供完善的健康检查与配置校验流程。
147 lines
4.4 KiB
Python
147 lines
4.4 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
from typing import Any, Literal
|
|
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
from yuxi.channels.adapters.bluebubbles.send import send_attachment_bytes
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
AudioFormat = Literal["ogg", "caf", "mp3"]
|
|
AUDIO_FORMAT_MIME_MAP: dict[str, str] = {
|
|
"ogg": "audio/ogg",
|
|
"caf": "audio/x-caf",
|
|
"mp3": "audio/mpeg",
|
|
}
|
|
SYNTHESIS_TARGET = "audio-file"
|
|
PREFERRED_AUDIO_FORMAT: AudioFormat = "caf"
|
|
SUPPORTED_AUDIO_FILE_FORMATS: list[str] = ["mp3", "caf", "audio/mpeg", "audio/x-caf"]
|
|
|
|
|
|
async def send_tts_voice(
|
|
client: BlueBubblesClient,
|
|
chat_guid: str,
|
|
text: str,
|
|
reply_to_guid: str | None = None,
|
|
fmt: AudioFormat = "caf",
|
|
) -> dict[str, Any]:
|
|
audio_data = await _generate_tts_audio(text, fmt)
|
|
if not audio_data:
|
|
return {"success": False, "error": "TTS generation failed"}
|
|
extension_map: dict[str, str] = {"ogg": "voice_message.ogg", "caf": "voice_message.caf", "mp3": "voice_message.mp3"}
|
|
filename = extension_map.get(fmt, "voice_message.caf")
|
|
return await send_attachment_bytes(
|
|
client,
|
|
chat_guid,
|
|
audio_data,
|
|
filename,
|
|
caption="",
|
|
reply_to_guid=reply_to_guid,
|
|
)
|
|
|
|
|
|
async def _generate_tts_audio(text: str, fmt: AudioFormat = "caf") -> bytes:
|
|
try:
|
|
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
|
|
tmp_path = tmp.name
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["espeak", "-w", tmp_path, text],
|
|
capture_output=True,
|
|
timeout=30,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"espeak failed: {result.stderr.decode()}")
|
|
|
|
if fmt == "ogg":
|
|
with open(tmp_path, "rb") as f:
|
|
return f.read()
|
|
|
|
return await _transcode_to_format(tmp_path, fmt)
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
except FileNotFoundError:
|
|
logger.warning("[BlueBubbles] espeak not found, TTS requires espeak to be installed")
|
|
return b""
|
|
except Exception as e:
|
|
logger.error(f"[BlueBubbles] TTS generation failed: {e}")
|
|
return b""
|
|
|
|
|
|
async def _transcode_to_format(input_path: str, fmt: AudioFormat) -> bytes:
|
|
import asyncio
|
|
import importlib.util
|
|
|
|
if importlib.util.find_spec("pydub") is not None:
|
|
from pydub import AudioSegment
|
|
|
|
return await asyncio.to_thread(_transcode_pydub, input_path, fmt, AudioSegment)
|
|
|
|
logger.warning("[BlueBubbles] pydub not available, falling back to ffmpeg")
|
|
return await _transcode_ffmpeg(input_path, fmt)
|
|
|
|
|
|
def _transcode_pydub(input_path: str, fmt: AudioFormat, AudioSegment) -> bytes:
|
|
audio = AudioSegment.from_file(input_path, format="ogg")
|
|
fmt_map: dict[str, str] = {"caf": "caf", "mp3": "mp3"}
|
|
out_fmt = fmt_map.get(fmt, "caf")
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=f".{out_fmt}", delete=False) as out_tmp:
|
|
out_path = out_tmp.name
|
|
|
|
try:
|
|
audio.export(out_path, format=out_fmt)
|
|
with open(out_path, "rb") as f:
|
|
return f.read()
|
|
finally:
|
|
try:
|
|
os.unlink(out_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
async def _transcode_ffmpeg(input_path: str, fmt: AudioFormat) -> bytes:
|
|
import asyncio
|
|
|
|
fmt_map: dict[str, str] = {
|
|
"caf": "caf",
|
|
"mp3": "mp3",
|
|
}
|
|
out_fmt = fmt_map.get(fmt, "caf")
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=f".{out_fmt}", delete=False) as out_tmp:
|
|
out_path = out_tmp.name
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i",
|
|
input_path,
|
|
"-acodec",
|
|
"pcm_s16le" if out_fmt == "caf" else "libmp3lame",
|
|
"-ar",
|
|
"44100" if out_fmt == "caf" else "24000",
|
|
out_path,
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
await proc.wait()
|
|
if proc.returncode != 0:
|
|
raise RuntimeError("ffmpeg transcoding failed")
|
|
|
|
with open(out_path, "rb") as f:
|
|
return f.read()
|
|
except FileNotFoundError:
|
|
logger.warning("[BlueBubbles] ffmpeg not found, cannot transcode")
|
|
with open(input_path, "rb") as f:
|
|
return f.read()
|
|
finally:
|
|
try:
|
|
os.unlink(out_path)
|
|
except OSError:
|
|
pass
|