ForcePilot/backend/package/yuxi/channels/adapters/feishu/tts.py
Kris a6fa7245e5 feat(feishu): 完整实现飞书适配器核心模块
新增飞书机器人适配器全套功能,包括:
- 基础适配器入口与工具导出
- 消息格式化、卡片渲染、回复调度逻辑
- 会话ID生成、模型覆盖策略
- 消息发送缓存、顺序队列管理
- 飞书签名验证、加解密webhook请求
- 审批权限校验、机器人菜单事件处理
- 文档评论、钉消息、语音转码处理
- 静态/动态目录管理、子代理生命周期管理
- 各类工具集:聊天、云盘、文档、知识库API封装
2026-05-12 00:43:59 +08:00

263 lines
7.7 KiB
Python

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'<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="zh-CN">'
f'<voice name="{voice}">{text}</voice>'
f"</speak>"
)
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