Files
ThinkStorm/thinkstorm/services/signal_gateway.py
T
xAdmin 41e08611c9 Implement optional single-image intake, Signal ingestion, and multimodal vision analysis
- Add image intake service with format validation (JPEG, PNG, WebP) and EXIF/GPS stripping
- Enforce strict single-image rule across Web and Signal attachment channels
- Implement token-optimized vision downscaling and JPEG compression
- Add IMAGE_CONTEXT pipeline stage with OmniRoute vision routing and resilient failover
- Seed and manage versioned idea-image-interpreter prompt in catalog
- Update Web UI with responsive image picker, preview chip, and Visual Context tab
- Add comprehensive automated test suite in test_image_intake.py
- Update README and Labyricorn devlog
2026-08-23 01:40:22 -07:00

198 lines
8.1 KiB
Python

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