from __future__ import annotations import logging from dataclasses import dataclass from typing import Any logger = logging.getLogger(__name__) try: import lark_oapi HAS_LARK_SDK = True except ImportError: HAS_LARK_SDK = False lark_oapi = None # type: ignore @dataclass class FeishuTTSConfig: enabled: bool = False mode: str = "off" provider: str = "openai" voice: str = "alloy" speed: float = 1.0 auto_tts: str = "off" summary_model: str = "" api_url: str = "" api_key: str = "" model: str = "tts-1" response_format: str = "mp3" @classmethod def from_config(cls, config: dict[str, Any] | None) -> FeishuTTSConfig: if not config: return cls() tts_cfg = config.get("tts", {}) or {} return cls( enabled=tts_cfg.get("enabled", False), mode=tts_cfg.get("mode", "off"), provider=tts_cfg.get("provider", "openai"), voice=tts_cfg.get("voice", "alloy"), speed=float(tts_cfg.get("speed", 1.0)), auto_tts=tts_cfg.get("auto", "off"), summary_model=tts_cfg.get("summaryModel", ""), api_url=tts_cfg.get("api_url", ""), api_key=tts_cfg.get("api_key", ""), model=tts_cfg.get("model", "tts-1"), response_format=tts_cfg.get("response_format", "mp3"), ) async def synthesize_tts( text: str, *, voice: str = "alloy", speed: float = 1.0, provider: str = "openai", api_url: str = "", api_key: str = "", model: str = "tts-1", response_format: str = "mp3", ) -> bytes | None: logger.info("[FeishuTTS] Synthesizing TTS: %d chars, voice=%s, provider=%s", len(text), voice, provider) if provider == "openai": return await _synthesize_openai(text, voice, speed, api_url, api_key, model, response_format) elif provider == "azure": return await _synthesize_azure(text, voice, api_url, api_key) elif provider == "custom": return await _synthesize_custom(text, voice, speed, api_url, api_key, response_format) else: logger.warning("[FeishuTTS] Unknown TTS provider: %s", provider) return None async def _synthesize_openai( text: str, voice: str, speed: float, api_url: str, api_key: str, model: str, response_format: str, ) -> bytes | None: import aiohttp url = api_url or "https://api.openai.com/v1/audio/speech" if not api_key: logger.warning("[FeishuTTS] OpenAI API key not configured") return None headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} payload = { "model": model, "input": text, "voice": voice, "speed": speed, "response_format": response_format, } try: async with aiohttp.ClientSession() as session: async with session.post( url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=60), ) as resp: if resp.status == 200: return await resp.read() logger.warning("[FeishuTTS] OpenAI TTS returned status %d: %s", resp.status, await resp.text()) return None except Exception as e: logger.warning("[FeishuTTS] OpenAI TTS call failed: %s", e) return None async def _synthesize_azure( text: str, voice: str, api_url: str, api_key: str, ) -> bytes | None: import aiohttp region = api_url or "eastasia" if not api_key: logger.warning("[FeishuTTS] Azure subscription key not configured") return None endpoint = f"https://{region}.tts.speech.microsoft.com/cognitiveservices/v1" ssml = ( f'' f'{text}' f"" ) headers = { "Ocp-Apim-Subscription-Key": api_key, "Content-Type": "application/ssml+xml", "X-Microsoft-OutputFormat": "audio-16khz-128kbitrate-mono-mp3", } try: async with aiohttp.ClientSession() as session: async with session.post( endpoint, headers=headers, data=ssml.encode(), timeout=aiohttp.ClientTimeout(total=60), ) as resp: if resp.status == 200: return await resp.read() logger.warning("[FeishuTTS] Azure TTS returned status %d", resp.status) return None except Exception as e: logger.warning("[FeishuTTS] Azure TTS call failed: %s", e) return None async def _synthesize_custom( text: str, voice: str, speed: float, api_url: str, api_key: str, response_format: str, ) -> bytes | None: import aiohttp if not api_url: logger.warning("[FeishuTTS] Custom TTS API URL not configured") return None headers = {"Content-Type": "application/json"} if api_key: headers["Authorization"] = f"Bearer {api_key}" payload = {"text": text, "voice": voice, "speed": speed, "format": response_format} try: async with aiohttp.ClientSession() as session: async with session.post( api_url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=60), ) as resp: if resp.status == 200: return await resp.read() logger.warning("[FeishuTTS] Custom TTS returned status %d", resp.status) return None except Exception as e: logger.warning("[FeishuTTS] Custom TTS call failed: %s", e) return None async def send_tts_audio( client: Any, chat_id: str, text: str, *, tts_config: FeishuTTSConfig | None = None, ) -> bool: cfg = tts_config or FeishuTTSConfig() if not cfg.enabled: return False audio_data = await synthesize_tts( text, voice=cfg.voice, speed=cfg.speed, provider=cfg.provider, api_url=cfg.api_url, api_key=cfg.api_key, model=cfg.model, response_format=cfg.response_format, ) if audio_data is None: logger.warning("[FeishuTTS] TTS synthesis returned no data") return False try: from .media import upload_file format_ext = cfg.response_format or "mp3" filename = f"tts_audio.{format_ext}" file_key = await upload_file(client, audio_data, filename, file_type="opus") if not file_key: logger.warning("[FeishuTTS] Failed to upload TTS audio file") return False import json content = json.dumps({"file_key": file_key}, ensure_ascii=False) receive_id = chat_id receive_id_type = "open_id" if chat_id.startswith("ou_") else "chat_id" import lark_oapi as _lark request = ( _lark.api.im.v1.CreateMessageRequest.builder() .receive_id_type(receive_id_type) .request_body( _lark.api.im.v1.CreateMessageRequestBody.builder() .receive_id(receive_id) .msg_type("audio") .content(content) .build() ) .build() ) resp = await client.im.message.create(request) if resp.success(): logger.info("[FeishuTTS] Audio message sent successfully, msg_id=%s", resp.data.get("message_id", "")) return True logger.warning("[FeishuTTS] Send audio message failed: %s", resp.msg) return False except Exception as e: logger.warning("[FeishuTTS] Send audio failed: %s", e) return False