- 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
198 lines
8.1 KiB
Python
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)}")
|