Add WeWork XiaoAi TTS bot - WeChat Work long connection bridge
Receives messages from WeChat Work bot via WebSocket long connection and speaks them through XiaoAi smart speaker TTS. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Any, Dict
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from miservice import MiAccount, MiNAService, MiTokenStore
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SafeTokenStore(MiTokenStore):
|
||||
"""Wraps MiTokenStore to never lose passToken on auth failure."""
|
||||
|
||||
def __init__(self, token_path):
|
||||
super().__init__(token_path)
|
||||
self._saved_pass_token = ""
|
||||
self._load_backup()
|
||||
|
||||
def _load_backup(self):
|
||||
path = Path(self.token_path)
|
||||
backup = Path(str(path) + ".backup")
|
||||
if backup.exists():
|
||||
try:
|
||||
data = json.loads(backup.read_text("utf-8"))
|
||||
self._saved_pass_token = data.get("passToken", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _save_backup(self, token):
|
||||
path = Path(self.token_path)
|
||||
backup = Path(str(path) + ".backup")
|
||||
try:
|
||||
backup.write_text(json.dumps(token, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def save_token(self, token=None):
|
||||
if token and token.get("passToken"):
|
||||
self._saved_pass_token = token["passToken"]
|
||||
self._save_backup(token)
|
||||
elif token is None and self._saved_pass_token:
|
||||
# miservice is trying to delete token after auth failure
|
||||
# Don't let it — restore from backup
|
||||
logger.warning("miservice tried to wipe token, restoring passToken...")
|
||||
return
|
||||
super().save_token(token)
|
||||
|
||||
|
||||
def _run_async_in_thread(coro, timeout: float = 15.0):
|
||||
result = None
|
||||
error = None
|
||||
|
||||
def _target():
|
||||
nonlocal result, error
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(coro)
|
||||
except Exception as e:
|
||||
error = e
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
t = threading.Thread(target=_target)
|
||||
t.start()
|
||||
t.join(timeout=timeout)
|
||||
if error:
|
||||
raise error
|
||||
return result
|
||||
|
||||
|
||||
def speak(text: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not config.TTS_ENABLED:
|
||||
logger.info("TTS disabled, skipping: %s", text)
|
||||
return True, {"skipped": True}
|
||||
|
||||
text = text[: config.TTS_MAX_TEXT_LENGTH].strip()
|
||||
if not text:
|
||||
return False, {"error": "empty text after truncation"}
|
||||
|
||||
async def _tts():
|
||||
token_store = SafeTokenStore(config.XIAOMI_TOKEN_PATH)
|
||||
async with ClientSession() as session:
|
||||
account = MiAccount(
|
||||
session, config.XIAOMI_USER_ID, None, token_store
|
||||
)
|
||||
mina = MiNAService(account)
|
||||
return await mina.text_to_speech(config.XIAOMI_SPEAKER_DID, text)
|
||||
|
||||
try:
|
||||
result = _run_async_in_thread(_tts(), timeout=config.TTS_TIMEOUT_SECONDS)
|
||||
ok = isinstance(result, dict) and result.get("code") == 0
|
||||
return ok, result or {}
|
||||
except Exception as e:
|
||||
logger.exception("TTS call failed")
|
||||
return False, {"error": str(e)}
|
||||
@@ -0,0 +1,140 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.services.tts import speak
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WSS_URL = "wss://openws.work.weixin.qq.com"
|
||||
PING_INTERVAL = 30
|
||||
|
||||
|
||||
async def _send(ws, cmd: str, body: dict | None = None, req_id: str | None = None):
|
||||
if req_id is None:
|
||||
req_id = uuid.uuid4().hex[:16]
|
||||
msg = {"cmd": cmd, "headers": {"req_id": req_id}}
|
||||
if body is not None:
|
||||
msg["body"] = body
|
||||
await ws.send_str(json.dumps(msg, ensure_ascii=False))
|
||||
return req_id
|
||||
|
||||
|
||||
async def _recv(ws) -> dict:
|
||||
msg = await ws.receive()
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
return json.loads(msg.data)
|
||||
if msg.type == aiohttp.WSMsgType.CLOSED:
|
||||
raise ConnectionError(f"WebSocket closed gracefully (code={msg.data})")
|
||||
if msg.type == aiohttp.WSMsgType.ERROR:
|
||||
raise ConnectionError(f"WebSocket error: {ws.exception()}")
|
||||
if msg.type == aiohttp.WSMsgType.CLOSE:
|
||||
raise ConnectionError(f"WebSocket closing (code={msg.data})")
|
||||
# PING, PONG, BINARY etc - ignore
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_text(msg: dict) -> str | None:
|
||||
body = msg.get("body", {})
|
||||
msgtype = body.get("msgtype", "")
|
||||
if msgtype == "text":
|
||||
return body.get("text", {}).get("content", "").strip() or None
|
||||
if msgtype == "voice":
|
||||
return body.get("voice", {}).get("content", "").strip() or None
|
||||
return None
|
||||
|
||||
|
||||
async def _handle_message(ws, msg: dict):
|
||||
text = _extract_text(msg)
|
||||
if not text:
|
||||
return
|
||||
|
||||
logger.info("Received: %s", text)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
success, result = await loop.run_in_executor(None, speak, text)
|
||||
|
||||
req_id = msg.get("headers", {}).get("req_id", "")
|
||||
|
||||
if success:
|
||||
logger.info("TTS success")
|
||||
reply_text = "已播报"
|
||||
else:
|
||||
logger.error("TTS failed: %s", result)
|
||||
reply_text = f"播报失败: {result.get('error', 'unknown')}"
|
||||
|
||||
await _send(ws, "aibot_respond_msg", {
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": uuid.uuid4().hex[:16],
|
||||
"finish": True,
|
||||
"content": reply_text,
|
||||
},
|
||||
}, req_id=req_id)
|
||||
|
||||
|
||||
async def _ping_loop(ws):
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(PING_INTERVAL)
|
||||
await _send(ws, "ping")
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Ping failed")
|
||||
break
|
||||
|
||||
|
||||
async def connect_and_serve():
|
||||
while True:
|
||||
try:
|
||||
await _run_connection()
|
||||
except Exception:
|
||||
logger.exception("Connection lost, reconnecting in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
async def _run_connection():
|
||||
logger.info("Connecting to %s ...", WSS_URL)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.ws_connect(
|
||||
WSS_URL, heartbeat=30, receive_timeout=300
|
||||
) as ws:
|
||||
logger.info("WebSocket connected")
|
||||
|
||||
await _send(ws, "aibot_subscribe", {
|
||||
"bot_id": config.WECOM_BOT_ID,
|
||||
"secret": config.WECOM_BOT_SECRET,
|
||||
})
|
||||
resp = await _recv(ws)
|
||||
if resp.get("errcode") != 0:
|
||||
logger.error("Subscribe failed: %s", resp)
|
||||
return
|
||||
logger.info("Subscribed successfully")
|
||||
|
||||
ping_task = asyncio.create_task(_ping_loop(ws))
|
||||
|
||||
try:
|
||||
while True:
|
||||
msg = await _recv(ws)
|
||||
cmd = msg.get("cmd", "")
|
||||
|
||||
if cmd == "aibot_msg_callback":
|
||||
asyncio.create_task(_handle_message(ws, msg))
|
||||
elif cmd == "aibot_event_callback":
|
||||
event_type = msg.get("body", {}).get("event", {}).get("eventtype", "")
|
||||
logger.info("Event: %s", event_type)
|
||||
elif cmd == "ping_response":
|
||||
pass
|
||||
elif cmd:
|
||||
logger.debug("Cmd: %s", cmd)
|
||||
finally:
|
||||
ping_task.cancel()
|
||||
try:
|
||||
await ping_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
Reference in New Issue
Block a user