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:
2026-05-03 13:52:04 +08:00
commit c7b8b01fe2
17 changed files with 762 additions and 0 deletions
View File
View File
+101
View File
@@ -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)}
+140
View File
@@ -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