""" Signal Gateway Service Adapter Manages outbound messaging and health checks with the Signal Gateway on the private LAN. """ import json import time import urllib.request import urllib.error import asyncio from typing import Dict, Any, Optional from .base import BaseServiceAdapter, ServiceHealth from ..config import config class SignalGatewayError(Exception): """Base exception for Signal Gateway operations.""" pass class SignalGatewayAuthError(SignalGatewayError): """Raised when authentication fails (HTTP 401).""" pass class SignalGatewayClientError(SignalGatewayError): """Raised when request payload is invalid (HTTP 400).""" pass class SignalGatewayPayloadTooLargeError(SignalGatewayError): """Raised when payload exceeds gateway size limit (HTTP 413).""" pass class SignalGatewayUnavailableError(SignalGatewayError): """Raised when the Signal Gateway service is unavailable (HTTP 503).""" pass class SignalGatewayTimeoutError(SignalGatewayError): """Raised when requests to the gateway time out.""" pass def mask_secret(secret: Optional[str]) -> str: """Safely masks secret credentials for logs/diagnostics.""" if not secret: return "" if len(secret) <= 8: return "***" return f"{secret[:4]}...{secret[-4:]}" class SignalGatewayAdapter(BaseServiceAdapter): def __init__(self, endpoint: str = "", api_key: str = ""): ep = endpoint or config.services.signal_gateway_base_url or "http://10.138.4.46:8000" key = api_key or config.services.signal_gateway_api_key or "" super().__init__(service_id="signal_gateway", endpoint=ep, api_key=key) self.timeout = config.services.signal_gateway_timeout_seconds def get_effective_api_key(self) -> str: """Retrieves configured API key from instance, config, or database configuration.""" if self.api_key: return self.api_key if config.services.signal_gateway_api_key: return config.services.signal_gateway_api_key try: from ..database import get_db with get_db() as conn: row = conn.execute("SELECT api_key_raw FROM service_configurations WHERE id = 'signal_gateway'").fetchone() if row and row["api_key_raw"]: return row["api_key_raw"] except Exception: pass return "" async def check_health(self) -> ServiceHealth: """Checks connectivity against Gateway /ready and /health endpoints.""" start = time.time() ready_url = f"{self.endpoint}/ready" health_url = f"{self.endpoint}/health" loop = asyncio.get_running_loop() def probe(): req = urllib.request.Request(ready_url, headers={"User-Agent": "ThinkStorm-Orchestrator/0.1"}) with urllib.request.urlopen(req, timeout=self.timeout) as resp: raw = resp.read().decode("utf-8") return resp.status, json.loads(raw) if raw else {} try: status_code, data = await loop.run_in_executor(None, probe) elapsed = int((time.time() - start) * 1000) is_ready = data.get("status") == "ready" return ServiceHealth( service_id=self.service_id, healthy=is_ready, endpoint=self.endpoint, message=f"Signal Gateway online (status: {data.get('status', 'ok')})", response_time_ms=elapsed, extra=data ) except urllib.error.HTTPError as e: elapsed = int((time.time() - start) * 1000) return ServiceHealth( service_id=self.service_id, healthy=False, endpoint=self.endpoint, message=f"Signal Gateway HTTP error: {e.code}", response_time_ms=elapsed ) except Exception as e: elapsed = int((time.time() - start) * 1000) return ServiceHealth( service_id=self.service_id, healthy=False, endpoint=self.endpoint, message=f"Signal Gateway unreachable: {str(e)}", response_time_ms=elapsed ) async def send_message(self, recipient: str, text: str, max_retries: int = 2) -> Dict[str, Any]: """ Sends an outbound message via Signal Gateway POST /api/v1/messages. Constraints: - recipient: nonempty, <= 256 bytes UTF-8 - text: 1 to 16,000 bytes UTF-8 - unknown fields rejected """ if not recipient or not recipient.strip(): raise SignalGatewayClientError("Recipient must not be empty.") recipient_bytes = recipient.encode("utf-8") if len(recipient_bytes) > 256: raise SignalGatewayClientError("Recipient exceeds maximum allowed length of 256 bytes.") if not text or not text.strip(): raise SignalGatewayClientError("Message text must not be empty.") text_bytes = text.encode("utf-8") if len(text_bytes) < 1 or len(text_bytes) > 16000: raise SignalGatewayPayloadTooLargeError("Message text must be between 1 and 16,000 bytes.") api_key = self.get_effective_api_key() if not api_key: raise SignalGatewayAuthError("Signal Gateway application API key is not configured.") url = f"{self.endpoint}/api/v1/messages" payload = { "recipient": recipient, "text": text } payload_data = json.dumps(payload).encode("utf-8") headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "User-Agent": "ThinkStorm-Orchestrator/0.1" } loop = asyncio.get_running_loop() def make_request(): req = urllib.request.Request(url, data=payload_data, headers=headers, method="POST") with urllib.request.urlopen(req, timeout=self.timeout) as resp: raw_resp = resp.read().decode("utf-8") return resp.status, json.loads(raw_resp) if raw_resp else {} attempt = 0 while True: try: status_code, data = await loop.run_in_executor(None, make_request) if status_code in (200, 202): return data return data except urllib.error.HTTPError as e: err_code = e.code err_body = "" try: err_body = e.read().decode("utf-8") err_json = json.loads(err_body) err_msg = err_json.get("error", {}).get("message", e.reason) except Exception: err_msg = e.reason if err_code == 400: raise SignalGatewayClientError(f"Invalid request (400): {err_msg}") elif err_code == 401: raise SignalGatewayAuthError("Signal Gateway application key is invalid or revoked (401).") elif err_code == 413: raise SignalGatewayPayloadTooLargeError(f"Request payload too large (413): {err_msg}") elif err_code == 503: if attempt < max_retries: attempt += 1 await asyncio.sleep(0.5 * attempt) continue raise SignalGatewayUnavailableError(f"Signal Gateway runtime or send queue unavailable (503): {err_msg}") else: raise SignalGatewayError(f"Signal Gateway HTTP error {err_code}: {err_msg}") except (TimeoutError, urllib.error.URLError) as e: if isinstance(e, urllib.error.URLError) and "timed out" in str(e.reason).lower(): raise SignalGatewayTimeoutError("Signal Gateway outbound request timed out.") if isinstance(e, TimeoutError): raise SignalGatewayTimeoutError("Signal Gateway outbound request timed out.") raise SignalGatewayError(f"Signal Gateway connection error: {str(e)}") except Exception as e: raise SignalGatewayError(f"Unexpected error communicating with Signal Gateway: {str(e)}")