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)}