""" AISTALK Adapter — HTTP bridge integration. Enables forwarding BFF events/messages to an external AISTALK bridge service. The adapter is best-effort and non-blocking for callers. """ from __future__ import annotations import logging import os import threading import time from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, List, Optional import httpx logger = logging.getLogger(__name__) def _split_paths(raw: str, default: str) -> List[str]: src = (raw or "").strip() if not src: src = default parts = [p.strip() for p in src.split(",") if p.strip()] normalized: List[str] = [] for p in parts: if not p.startswith("/"): p = "/" + p normalized.append(p) return normalized class AISTALKAdapter: """ AISTALK relay adapter. Env overrides (optional): AISTALK_HEALTH_PATHS=/healthz,/health,/api/health AISTALK_EVENT_PATHS=/api/events,/events,/v1/events AISTALK_TEXT_PATHS=/api/text,/text,/v1/text AISTALK_AUDIO_PATHS=/api/audio,/audio,/v1/audio """ def __init__(self, base_url: str, api_key: Optional[str] = None) -> None: self.base_url = base_url.rstrip("/") if base_url else "" self.api_key = api_key or "" self._enabled = bool(self.base_url) self._health_paths = _split_paths( os.getenv("AISTALK_HEALTH_PATHS", ""), "/healthz,/health,/api/health", ) self._event_paths = _split_paths( os.getenv("AISTALK_EVENT_PATHS", ""), "/api/events,/events,/v1/events", ) self._text_paths = _split_paths( os.getenv("AISTALK_TEXT_PATHS", ""), "/api/text,/text,/v1/text", ) self._audio_paths = _split_paths( os.getenv("AISTALK_AUDIO_PATHS", ""), "/api/audio,/audio,/v1/audio", ) self._lock = threading.Lock() self._last_ok_at: Optional[float] = None self._last_error: str = "" self._last_endpoint: str = "" self._last_probe_ok: Optional[bool] = None self._last_probe_at: Optional[float] = None # Fire-and-forget outbound queue to avoid adding latency to BFF handlers. self._pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="aistalk-relay") if self._enabled: logger.info("AISTALKAdapter init: url=%s (HTTP relay mode)", self.base_url) else: logger.info("AISTALKAdapter init: no base_url, adapter disabled") @property def enabled(self) -> bool: return self._enabled def _headers(self) -> Dict[str, str]: headers = {"Content-Type": "application/json"} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" headers["X-API-Key"] = self.api_key return headers def _mark_ok(self, endpoint: str) -> None: with self._lock: self._last_ok_at = time.time() self._last_error = "" self._last_endpoint = endpoint def _mark_err(self, err: str) -> None: with self._lock: self._last_error = (err or "")[:300] def _post_json(self, payload: Dict[str, Any], paths: List[str], kind: str) -> bool: if not self._enabled: return False last_err = "unreachable" timeout = httpx.Timeout(connect=0.6, read=1.8, write=1.8, pool=0.6) for path in paths: endpoint = f"{self.base_url}{path}" try: with httpx.Client(timeout=timeout) as client: r = client.post(endpoint, headers=self._headers(), json=payload) if 200 <= r.status_code < 300: self._mark_ok(endpoint) return True last_err = f"HTTP {r.status_code} @ {path}" except Exception as e: last_err = f"{e.__class__.__name__}: {str(e)[:180]} @ {path}" continue self._mark_err(last_err) logger.debug("AISTALK %s relay failed: %s", kind, last_err) return False def _post_audio(self, payload: Dict[str, Any], audio_bytes: bytes, mime: str) -> bool: if not self._enabled: return False last_err = "unreachable" timeout = httpx.Timeout(connect=0.8, read=2.5, write=2.5, pool=0.8) for path in self._audio_paths: endpoint = f"{self.base_url}{path}" files = {"audio": ("chunk", audio_bytes, mime or "audio/wav")} data = {"meta": str(payload)} try: with httpx.Client(timeout=timeout) as client: headers = {} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" headers["X-API-Key"] = self.api_key r = client.post(endpoint, headers=headers, data=data, files=files) if 200 <= r.status_code < 300: self._mark_ok(endpoint) return True last_err = f"HTTP {r.status_code} @ {path}" except Exception as e: last_err = f"{e.__class__.__name__}: {str(e)[:180]} @ {path}" continue self._mark_err(last_err) logger.debug("AISTALK audio relay failed: %s", last_err) return False def _dispatch(self, fn, *args: Any) -> None: if not self._enabled: return try: self._pool.submit(fn, *args) except Exception as e: self._mark_err(str(e)) logger.debug("AISTALK dispatch failed: %s", e) def send_text( self, project_id: str, session_id: str, text: str, user_id: str = "console_user", ) -> None: if not self._enabled: return payload = { "v": 1, "type": "chat.reply", "project_id": project_id, "session_id": session_id, "user_id": user_id, "data": {"text": text}, } self._dispatch(self._post_json, payload, self._text_paths, "text") def send_audio( self, project_id: str, session_id: str, audio_bytes: bytes, mime: str = "audio/wav", ) -> None: if not self._enabled: return payload = { "v": 1, "type": "voice.tts", "project_id": project_id, "session_id": session_id, "user_id": "console_user", "data": {"mime": mime, "bytes": len(audio_bytes)}, } self._dispatch(self._post_audio, payload, audio_bytes, mime) def handle_event(self, event: Dict[str, Any]) -> None: if not self._enabled: return self._dispatch(self._post_json, event, self._event_paths, "event") def on_event(self, event: Dict[str, Any]) -> None: self.handle_event(event) def probe_health(self) -> Dict[str, Any]: if not self._enabled: return {"enabled": False, "ok": False, "error": "disabled"} timeout = httpx.Timeout(connect=0.5, read=1.2, write=1.2, pool=0.5) last_err = "unreachable" for path in self._health_paths: endpoint = f"{self.base_url}{path}" try: with httpx.Client(timeout=timeout) as client: headers = {} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" headers["X-API-Key"] = self.api_key r = client.get(endpoint, headers=headers) if r.status_code < 500: with self._lock: self._last_probe_ok = r.status_code == 200 self._last_probe_at = time.time() if r.status_code == 200: self._mark_ok(endpoint) return {"enabled": True, "ok": True, "url": endpoint, "status": r.status_code} last_err = f"HTTP {r.status_code} @ {path}" else: last_err = f"HTTP {r.status_code} @ {path}" except Exception as e: last_err = f"{e.__class__.__name__}: {str(e)[:180]} @ {path}" continue with self._lock: self._last_probe_ok = False self._last_probe_at = time.time() self._mark_err(last_err) return {"enabled": True, "ok": False, "error": last_err} def status(self) -> Dict[str, Any]: with self._lock: return { "enabled": self._enabled, "base_url": self.base_url, "last_ok_at": self._last_ok_at, "last_endpoint": self._last_endpoint, "last_error": self._last_error, "last_probe_ok": self._last_probe_ok, "last_probe_at": self._last_probe_at, "paths": { "health": self._health_paths, "events": self._event_paths, "text": self._text_paths, "audio": self._audio_paths, }, } def __repr__(self) -> str: s = self.status() return ( f"AISTALKAdapter(url={s['base_url']!r}, enabled={s['enabled']}, " f"last_probe_ok={s['last_probe_ok']}, last_endpoint={s['last_endpoint']!r})" )