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
This commit is contained in:
+159
-137
@@ -2,17 +2,23 @@
|
||||
Gitea Service Adapter
|
||||
Handles Idea Dossier repository creation, full document tree synchronization,
|
||||
organization management (under 'thinkstorm' org), and OAuth2 authentication.
|
||||
Uses direct Gitea REST API v1 over HTTPS for fast, reliable, atomic synchronization.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import re
|
||||
import base64
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import socket
|
||||
import asyncio
|
||||
import subprocess
|
||||
import urllib3.util.connection as urllib3_cn
|
||||
|
||||
try:
|
||||
urllib3_cn.allowed_gai_family = lambda: socket.AF_INET
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
@@ -41,27 +47,45 @@ class GiteaAdapter(BaseServiceAdapter):
|
||||
pass
|
||||
return config.services.gitea_api_token or ""
|
||||
|
||||
def _get_headers(self, token: Optional[str] = None) -> Dict[str, str]:
|
||||
t = token or self.get_effective_token()
|
||||
headers = {
|
||||
"User-Agent": "ThinkStorm-Orchestrator/0.1",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if t:
|
||||
headers["Authorization"] = f"token {t}"
|
||||
return headers
|
||||
|
||||
async def check_health(self) -> ServiceHealth:
|
||||
start = time.time()
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
url = f"{self.endpoint}/api/v1/version"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "ThinkStorm-Orchestrator/0.1"})
|
||||
loop = asyncio.get_running_loop()
|
||||
def fetch():
|
||||
with urllib.request.urlopen(req, timeout=5.0) as resp:
|
||||
return resp.read()
|
||||
raw = await loop.run_in_executor(None, fetch)
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
version = data.get("version", "unknown")
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
return ServiceHealth(
|
||||
service_id=self.service_id,
|
||||
healthy=True,
|
||||
endpoint=self.endpoint,
|
||||
message=f"Gitea online (v{version})",
|
||||
response_time_ms=elapsed,
|
||||
extra={"version": version}
|
||||
)
|
||||
return requests.get(url, headers={"User-Agent": "ThinkStorm-Orchestrator/0.1"}, timeout=6.0)
|
||||
resp = await loop.run_in_executor(None, fetch)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
version = data.get("version", "unknown")
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
return ServiceHealth(
|
||||
service_id=self.service_id,
|
||||
healthy=True,
|
||||
endpoint=self.endpoint,
|
||||
message=f"Gitea online (v{version})",
|
||||
response_time_ms=elapsed,
|
||||
extra={"version": version}
|
||||
)
|
||||
else:
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
return ServiceHealth(
|
||||
service_id=self.service_id,
|
||||
healthy=False,
|
||||
endpoint=self.endpoint,
|
||||
message=f"Gitea HTTP error: {resp.status_code}",
|
||||
response_time_ms=elapsed
|
||||
)
|
||||
except Exception as e:
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
return ServiceHealth(
|
||||
@@ -72,66 +96,80 @@ class GiteaAdapter(BaseServiceAdapter):
|
||||
response_time_ms=elapsed
|
||||
)
|
||||
|
||||
def _ensure_org(self, session: requests.Session, token: str) -> None:
|
||||
"""Ensures the 'thinkstorm' organization exists."""
|
||||
headers = self._get_headers(token)
|
||||
try:
|
||||
r = session.get(f"{self.endpoint}/api/v1/orgs/{self.org_name}", headers=headers, timeout=8)
|
||||
if r.status_code == 200:
|
||||
return
|
||||
if r.status_code == 404:
|
||||
payload = {
|
||||
"username": self.org_name,
|
||||
"full_name": "ThinkStorm Idea Incubation",
|
||||
"description": "Canonical dossiers, research, and project incubations generated by ThinkStorm",
|
||||
"visibility": "public"
|
||||
}
|
||||
session.post(f"{self.endpoint}/api/v1/orgs", json=payload, headers=headers, timeout=8)
|
||||
except Exception as e:
|
||||
print(f"[Gitea] Org ensure notice: {e}")
|
||||
|
||||
def _ensure_repo(self, session: requests.Session, token: str, repo_name: str, description: str) -> bool:
|
||||
"""Ensures the repository exists under the organization."""
|
||||
headers = self._get_headers(token)
|
||||
try:
|
||||
r = session.get(f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_name}", headers=headers, timeout=8)
|
||||
if r.status_code == 200:
|
||||
return True
|
||||
if r.status_code == 404:
|
||||
payload = {
|
||||
"name": repo_name,
|
||||
"description": description,
|
||||
"private": False,
|
||||
"auto_init": True,
|
||||
"default_branch": "main"
|
||||
}
|
||||
cr = session.post(f"{self.endpoint}/api/v1/orgs/{self.org_name}/repos", json=payload, headers=headers, timeout=10)
|
||||
return cr.status_code in (200, 201)
|
||||
except Exception as e:
|
||||
print(f"[Gitea] Repo ensure error for {repo_name}: {e}")
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sync_files_via_api(self, token: str, repo_slug: str, files_dict: Dict[str, str]) -> None:
|
||||
"""Commits or updates multiple files directly into the Gitea repository via Contents API."""
|
||||
script_payload = {
|
||||
"token": token,
|
||||
"repo": f"{self.org_name}/{repo_slug}",
|
||||
"files": files_dict
|
||||
}
|
||||
|
||||
py_code = f"""
|
||||
import urllib.request, json, base64, sys
|
||||
if not files_dict or not token:
|
||||
return
|
||||
|
||||
data = json.loads({json.dumps(json.dumps(script_payload))})
|
||||
token = data['token']
|
||||
repo = data['repo']
|
||||
files = data['files']
|
||||
headers = self._get_headers(token)
|
||||
with requests.Session() as s:
|
||||
for path, content in files_dict.items():
|
||||
try:
|
||||
# 1. Check if file already exists in repo to get current SHA
|
||||
get_url = f"{self.endpoint}/api/v1/repos/{self.org_name}/{repo_slug}/contents/{path}"
|
||||
r = s.get(get_url, headers=headers, timeout=8)
|
||||
sha = None
|
||||
if r.status_code == 200:
|
||||
sha = r.json().get("sha")
|
||||
|
||||
for path, content in files.items():
|
||||
get_url = f'https://git.labyricorn.com/api/v1/repos/{{repo}}/contents/{{path}}'
|
||||
sha = None
|
||||
try:
|
||||
req = urllib.request.Request(get_url, headers={{'Authorization': f'token {{token}}', 'User-Agent': 'ThinkStorm/0.1'}})
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
d = json.loads(resp.read().decode('utf-8'))
|
||||
sha = d.get('sha')
|
||||
except Exception:
|
||||
pass
|
||||
b64_content = base64.b64encode(content.encode("utf-8")).decode("utf-8")
|
||||
payload = {
|
||||
"content": b64_content,
|
||||
"message": f"Sync {path} into ThinkStorm dossier",
|
||||
"branch": "main"
|
||||
}
|
||||
|
||||
payload = {{
|
||||
'content': base64.b64encode(content.encode('utf-8')).decode('utf-8'),
|
||||
'message': f'Sync {{path}} into ThinkStorm dossier',
|
||||
'branch': 'main'
|
||||
}}
|
||||
if sha:
|
||||
payload['sha'] = sha
|
||||
method = 'PUT'
|
||||
else:
|
||||
method = 'POST'
|
||||
|
||||
url = f'https://git.labyricorn.com/api/v1/repos/{{repo}}/contents/{{path}}'
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode('utf-8'),
|
||||
headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}', 'User-Agent': 'ThinkStorm/0.1'}},
|
||||
method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f'File sync notice for {{path}}: {{e}}')
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "[email protected]", f"python3 -c {json.dumps(py_code)}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=25
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Gitea] Remote file commit notice: {e}")
|
||||
if sha:
|
||||
payload["sha"] = sha
|
||||
put_res = s.put(get_url, json=payload, headers=headers, timeout=10)
|
||||
if put_res.status_code not in (200, 201):
|
||||
print(f"[Gitea] Update notice for {path}: {put_res.status_code} {put_res.text[:100]}")
|
||||
else:
|
||||
post_res = s.post(get_url, json=payload, headers=headers, timeout=10)
|
||||
if post_res.status_code not in (200, 201):
|
||||
print(f"[Gitea] Create notice for {path}: {post_res.status_code} {post_res.text[:100]}")
|
||||
except Exception as e:
|
||||
print(f"[Gitea] File sync notice for {path}: {e}")
|
||||
|
||||
async def persist_idea_dossier_repo(
|
||||
self,
|
||||
@@ -145,7 +183,8 @@ for path, content in files.items():
|
||||
research_docs: Dict[str, str],
|
||||
outputs: Dict[str, str],
|
||||
provenance_runs: List[Dict[str, Any]],
|
||||
existing_repo_url: Optional[str] = None
|
||||
existing_repo_url: Optional[str] = None,
|
||||
submission_image: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Creates a dedicated Gitea Project repository and synchronizes all files into it."""
|
||||
# 1. Local disk directory
|
||||
@@ -157,6 +196,27 @@ for path, content in files.items():
|
||||
|
||||
cat_str = ", ".join(categories) if categories else "General"
|
||||
tag_str = ", ".join(f"#{t}" for t in tags) if tags else "None"
|
||||
|
||||
img_section = ""
|
||||
if submission_image and submission_image.get("present"):
|
||||
art_id = submission_image.get("artifact_id", "IMG-TS")
|
||||
mime = submission_image.get("mime_type", "image/jpeg")
|
||||
w = submission_image.get("width", 0)
|
||||
h = submission_image.get("height", 0)
|
||||
size_kb = round(submission_image.get("size_bytes", 0) / 1024, 1)
|
||||
sha = submission_image.get("sha256", "")
|
||||
img_section = (
|
||||
f"## Reference Image\n\n"
|
||||
f"- **Artifact ID:** `{art_id}`\n"
|
||||
f"- **MIME Type:** `{mime}`\n"
|
||||
f"- **Dimensions:** `{w}x{h}`\n"
|
||||
f"- **File Size:** `{size_kb} KB`\n"
|
||||
f"- **SHA-256:** `{sha}`\n\n"
|
||||
)
|
||||
|
||||
ctx_section = ""
|
||||
if "image-context.md" in research_docs:
|
||||
ctx_section = f"## Image Context\n\n{research_docs['image-context.md']}\n\n"
|
||||
|
||||
# 2. Build README.md
|
||||
readme_md = (
|
||||
@@ -166,6 +226,8 @@ for path, content in files.items():
|
||||
f"**Tags:** {tag_str} \n\n"
|
||||
f"## Executive Summary\n{summary}\n\n"
|
||||
f"## Original Submission Prompt\n> {original_text.strip()}\n\n"
|
||||
f"{img_section}"
|
||||
f"{ctx_section}"
|
||||
f"## Project Structure\n"
|
||||
f"- `research/`: Automated competitor analysis, prior art, deep research, and technical feasibility reports.\n"
|
||||
f"- `outputs/`: Multi-modal work track deliverables (articles, code scaffolds, business models).\n"
|
||||
@@ -192,6 +254,7 @@ for path, content in files.items():
|
||||
# 4. Research docs
|
||||
files_to_sync = {
|
||||
"README.md": readme_md,
|
||||
"idea.md": readme_md,
|
||||
"metadata.json": meta_json_str
|
||||
}
|
||||
|
||||
@@ -210,7 +273,9 @@ for path, content in files.items():
|
||||
# 6. Provenance
|
||||
for run in provenance_runs:
|
||||
run_id = run.get("id", f"run-{int(time.time())}")
|
||||
(idea_dir / "provenance" / f"{run_id}.json").write_text(json.dumps(run, indent=2), encoding="utf-8")
|
||||
run_json = json.dumps(run, indent=2)
|
||||
(idea_dir / "provenance" / f"{run_id}.json").write_text(run_json, encoding="utf-8")
|
||||
files_to_sync[f"provenance/{run_id}.json"] = run_json
|
||||
|
||||
# 7. Gitea Repository sync under 'thinkstorm' organization
|
||||
clean_slug = re.sub(r'[^a-zA-Z0-9_-]', '-', idea_id.lower()).strip('-')
|
||||
@@ -224,37 +289,10 @@ for path, content in files.items():
|
||||
loop = asyncio.get_running_loop()
|
||||
def sync_gitea():
|
||||
try:
|
||||
# 1. Create repo under org if missing
|
||||
create_script = f"""
|
||||
import urllib.request, json
|
||||
token = '{token}'
|
||||
repo_name = '{repo_name}'
|
||||
org = '{self.org_name}'
|
||||
|
||||
# Ensure org
|
||||
try:
|
||||
req = urllib.request.Request(f'https://git.labyricorn.com/api/v1/orgs/{{org}}', headers={{'Authorization': f'token {{token}}'}})
|
||||
with urllib.request.urlopen(req, timeout=5): pass
|
||||
except Exception:
|
||||
try:
|
||||
req = urllib.request.Request('https://git.labyricorn.com/api/v1/orgs', data=json.dumps({{'username': org, 'visibility': 'public'}}).encode(), headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}'}}, method='POST')
|
||||
with urllib.request.urlopen(req, timeout=5): pass
|
||||
except Exception: pass
|
||||
|
||||
# Create repo
|
||||
try:
|
||||
payload = {{'name': repo_name, 'description': f'[{idea_id}] {title}', 'private': False, 'auto_init': True}}
|
||||
req = urllib.request.Request(f'https://git.labyricorn.com/api/v1/orgs/{{org}}/repos', data=json.dumps(payload).encode(), headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}'}}, method='POST')
|
||||
with urllib.request.urlopen(req, timeout=6): pass
|
||||
except Exception: pass
|
||||
"""
|
||||
subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "[email protected]", f"python3 -c {json.dumps(create_script)}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
with requests.Session() as s:
|
||||
# 1. Ensure org and repo exist
|
||||
self._ensure_org(s, token)
|
||||
self._ensure_repo(s, token, repo_name, f"[{idea_id}] {title}")
|
||||
# 2. Push full file tree to Gitea
|
||||
self._sync_files_via_api(token, repo_name, files_to_sync)
|
||||
return f"{self.endpoint}/{self.org_name}/{repo_name}"
|
||||
@@ -301,7 +339,11 @@ except Exception: pass
|
||||
if token and files_to_sync:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await loop.run_in_executor(None, lambda: self._sync_files_via_api(token, repo_name, files_to_sync))
|
||||
def do_sync():
|
||||
with requests.Session() as s:
|
||||
self._ensure_repo(s, token, repo_name, f"[{idea_id}] Project Dossier")
|
||||
self._sync_files_via_api(token, repo_name, files_to_sync)
|
||||
await loop.run_in_executor(None, do_sync)
|
||||
except Exception as e:
|
||||
print(f"[Gitea] Work track sync notice: {e}")
|
||||
|
||||
@@ -322,23 +364,9 @@ except Exception: pass
|
||||
loop = asyncio.get_running_loop()
|
||||
def create_remote():
|
||||
try:
|
||||
create_script = f"""
|
||||
import urllib.request, json
|
||||
token = '{token}'
|
||||
slug = '{slug}'
|
||||
org = '{self.org_name}'
|
||||
payload = {{'name': slug, 'description': f'[{idea_id}] {title}', 'private': False, 'auto_init': True}}
|
||||
req = urllib.request.Request(f'https://git.labyricorn.com/api/v1/orgs/{{org}}/repos', data=json.dumps(payload).encode(), headers={{'Content-Type': 'application/json', 'Authorization': f'token {{token}}'}}, method='POST')
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=6): pass
|
||||
except Exception: pass
|
||||
"""
|
||||
subprocess.run(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "[email protected]", f"python3 -c {json.dumps(create_script)}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
with requests.Session() as s:
|
||||
self._ensure_org(s, token)
|
||||
self._ensure_repo(s, token, slug, f"[{idea_id}] {title}")
|
||||
return f"{self.endpoint}/{self.org_name}/{slug}"
|
||||
except Exception as e:
|
||||
print(f"[Gitea] Project graduation notice: {e}")
|
||||
@@ -355,20 +383,14 @@ except Exception: pass
|
||||
async def verify_oauth_user(self, access_token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Fetches user profile details using Gitea OAuth access token."""
|
||||
url = f"{self.endpoint}/api/v1/user"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"token {access_token}",
|
||||
"User-Agent": "ThinkStorm-Orchestrator/0.1"
|
||||
}
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
def fetch():
|
||||
with urllib.request.urlopen(req, timeout=8.0) as resp:
|
||||
return resp.read()
|
||||
return requests.get(url, headers=self._get_headers(access_token), timeout=8.0)
|
||||
try:
|
||||
raw = await loop.run_in_executor(None, fetch)
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
resp = await loop.run_in_executor(None, fetch)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[Gitea] OAuth user verify error: {e}")
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
ThinkStorm Image Intake & Sanitization Service
|
||||
Handles safe image validation, EXIF/GPS metadata stripping, token-optimized vision resizing,
|
||||
durable artifact persistence, and canonical metadata generation.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from ..config import config, BASE_DIR
|
||||
|
||||
ARTIFACTS_DIR = BASE_DIR / "data" / "artifacts"
|
||||
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Map PIL format strings to canonical MIME types and file extensions
|
||||
FORMAT_TO_MIME = {
|
||||
"JPEG": "image/jpeg",
|
||||
"PNG": "image/png",
|
||||
"WEBP": "image/webp"
|
||||
}
|
||||
|
||||
MIME_TO_EXT = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp"
|
||||
}
|
||||
|
||||
class ImageValidationError(Exception):
|
||||
"""Raised when an uploaded or received file fails image validation."""
|
||||
pass
|
||||
|
||||
class ImageTooLargeError(ImageValidationError):
|
||||
"""Raised when an image exceeds configured size limit."""
|
||||
pass
|
||||
|
||||
class ImageFormatError(ImageValidationError):
|
||||
"""Raised when image format is unsupported or invalid."""
|
||||
pass
|
||||
|
||||
def sanitize_filename(filename: Optional[str], default_ext: str = ".jpg") -> str:
|
||||
"""Strips path traversal elements and unsafe characters from original filename."""
|
||||
if not filename:
|
||||
return f"submission_image{default_ext}"
|
||||
base = os.path.basename(filename).strip()
|
||||
# Remove null bytes and non-printable characters
|
||||
clean = "".join(c for c in base if c.isalnum() or c in "._- ")
|
||||
if not clean or clean.startswith("."):
|
||||
return f"submission_image{default_ext}"
|
||||
return clean[:128]
|
||||
|
||||
def validate_and_sanitize_image(
|
||||
raw_bytes: bytes,
|
||||
original_filename: str = "",
|
||||
source: str = "web",
|
||||
idea_id: Optional[str] = None
|
||||
) -> Tuple[bytes, Dict[str, Any]]:
|
||||
"""
|
||||
Validates, strips metadata, and generates canonical sanitized image bytes and metadata.
|
||||
|
||||
Returns:
|
||||
Tuple[bytes, Dict[str, Any]]: (sanitized_bytes, metadata_dict)
|
||||
|
||||
Raises:
|
||||
ImageTooLargeError: If payload exceeds max size.
|
||||
ImageFormatError: If data is corrupted or unsupported format.
|
||||
"""
|
||||
if not raw_bytes:
|
||||
raise ImageFormatError("Image payload cannot be empty.")
|
||||
|
||||
max_bytes = config.max_image_upload_bytes
|
||||
if len(raw_bytes) > max_bytes:
|
||||
raise ImageTooLargeError(
|
||||
f"Image size ({len(raw_bytes)} bytes) exceeds maximum limit of {max_bytes} bytes ({max_bytes // (1024 * 1024)}MB)."
|
||||
)
|
||||
|
||||
# 1. Open and verify image structure with Pillow
|
||||
try:
|
||||
in_stream = io.BytesIO(raw_bytes)
|
||||
img = Image.open(in_stream)
|
||||
img_format = img.format
|
||||
except Exception as e:
|
||||
raise ImageFormatError(f"Could not decode image header: {str(e)}")
|
||||
|
||||
if not img_format or img_format.upper() not in FORMAT_TO_MIME:
|
||||
allowed_str = ", ".join(config.allowed_image_mime_types)
|
||||
raise ImageFormatError(
|
||||
f"Unsupported image format: '{img_format}'. Supported formats are: {allowed_str} (JPEG, PNG, WebP)."
|
||||
)
|
||||
|
||||
canonical_mime = FORMAT_TO_MIME[img_format.upper()]
|
||||
|
||||
# 2. Decode pixel data to ensure file is not truncated or corrupted
|
||||
try:
|
||||
# Respect EXIF orientation before stripping EXIF metadata
|
||||
img = ImageOps.exif_transpose(img)
|
||||
img.load()
|
||||
except Exception as e:
|
||||
raise ImageFormatError(f"Malformed or corrupt image pixel stream: {str(e)}")
|
||||
|
||||
# 3. Create sanitized copy (stripping EXIF, GPS, camera metadata, comments)
|
||||
out_format = img_format.upper()
|
||||
out_stream = io.BytesIO()
|
||||
|
||||
try:
|
||||
if out_format == "JPEG":
|
||||
# Convert palette/RGBA modes to RGB for JPEG
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
rgb_img = Image.new("RGB", img.size, (255, 255, 255))
|
||||
if img.mode == "P":
|
||||
img = img.convert("RGBA")
|
||||
rgb_img.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
|
||||
img = rgb_img
|
||||
elif img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
img.save(out_stream, format="JPEG", quality=92, optimize=True)
|
||||
|
||||
elif out_format == "PNG":
|
||||
# Preserve RGB / RGBA transparency
|
||||
if img.mode not in ("RGB", "RGBA", "L", "LA"):
|
||||
img = img.convert("RGBA" if "transparency" in img.info else "RGB")
|
||||
img.save(out_stream, format="PNG", optimize=True)
|
||||
|
||||
elif out_format == "WEBP":
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGBA" if "transparency" in img.info else "RGB")
|
||||
img.save(out_stream, format="WEBP", quality=90, method=6)
|
||||
else:
|
||||
raise ImageFormatError(f"Unsupported save format: {out_format}")
|
||||
|
||||
except Exception as e:
|
||||
raise ImageFormatError(f"Failed to encode sanitized image: {str(e)}")
|
||||
|
||||
sanitized_bytes = out_stream.getvalue()
|
||||
width, height = img.size
|
||||
sha256_hash = hashlib.sha256(sanitized_bytes).hexdigest()
|
||||
ext = MIME_TO_EXT.get(canonical_mime, ".jpg")
|
||||
safe_filename = sanitize_filename(original_filename, default_ext=ext)
|
||||
|
||||
artifact_id = f"IMG-{idea_id}" if idea_id else f"IMG-{sha256_hash[:12].upper()}"
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
"present": True,
|
||||
"artifact_id": artifact_id,
|
||||
"original_filename": safe_filename,
|
||||
"mime_type": canonical_mime,
|
||||
"size_bytes": len(sanitized_bytes),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"sha256": sha256_hash,
|
||||
"source": source,
|
||||
"vision_analysis_run_id": None
|
||||
}
|
||||
|
||||
return sanitized_bytes, metadata
|
||||
|
||||
def save_image_artifact(idea_id: str, sanitized_bytes: bytes, metadata: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Saves the canonical sanitized image file to the idea's artifact directory.
|
||||
Returns relative or absolute path to saved artifact.
|
||||
"""
|
||||
idea_dir = ARTIFACTS_DIR / idea_id
|
||||
idea_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
artifact_id = metadata.get("artifact_id") or f"IMG-{idea_id}"
|
||||
mime = metadata.get("mime_type", "image/jpeg")
|
||||
ext = MIME_TO_EXT.get(mime, ".jpg")
|
||||
|
||||
file_path = idea_dir / f"{artifact_id}{ext}"
|
||||
file_path.write_bytes(sanitized_bytes)
|
||||
|
||||
# Also keep a predictable reference file for easy retrieval
|
||||
canonical_link = idea_dir / f"reference_image{ext}"
|
||||
if canonical_link != file_path:
|
||||
canonical_link.write_bytes(sanitized_bytes)
|
||||
|
||||
return str(file_path)
|
||||
|
||||
def get_image_artifact_path(idea_id: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[Path]:
|
||||
"""Retrieves file path to an idea's saved reference image."""
|
||||
idea_dir = ARTIFACTS_DIR / idea_id
|
||||
if not idea_dir.exists():
|
||||
return None
|
||||
|
||||
if metadata and metadata.get("artifact_id"):
|
||||
artifact_id = metadata["artifact_id"]
|
||||
mime = metadata.get("mime_type", "image/jpeg")
|
||||
ext = MIME_TO_EXT.get(mime, ".jpg")
|
||||
path = idea_dir / f"{artifact_id}{ext}"
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
# Fallback to search any image in idea artifact directory
|
||||
for candidate_ext in [".jpg", ".jpeg", ".png", ".webp"]:
|
||||
ref = idea_dir / f"reference_image{candidate_ext}"
|
||||
if ref.exists():
|
||||
return ref
|
||||
img_match = list(idea_dir.glob(f"IMG-*{candidate_ext}"))
|
||||
if img_match:
|
||||
return img_match[0]
|
||||
|
||||
return None
|
||||
|
||||
def create_token_optimized_vision_payload(
|
||||
sanitized_bytes: bytes,
|
||||
mime_type: str = "image/jpeg"
|
||||
) -> Tuple[bytes, str]:
|
||||
"""
|
||||
Optimizes and downscales image dimensions to bound vision LLM token consumption.
|
||||
Bounds maximum dimension to `config.vision_max_dimension` (default 1536px)
|
||||
and compresses to efficient JPEG to minimize token expenditure across vision grid tiles.
|
||||
|
||||
Returns:
|
||||
Tuple[bytes, str]: (optimized_bytes, optimized_mime_type)
|
||||
"""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(sanitized_bytes))
|
||||
width, height = img.size
|
||||
max_dim = config.vision_max_dimension
|
||||
|
||||
# Downscale if larger than max_dim while preserving aspect ratio
|
||||
if width > max_dim or height > max_dim:
|
||||
img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)
|
||||
|
||||
# Convert to RGB if needed for JPEG compression
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
rgb_img = Image.new("RGB", img.size, (255, 255, 255))
|
||||
if img.mode == "P":
|
||||
img = img.convert("RGBA")
|
||||
rgb_img.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
|
||||
img = rgb_img
|
||||
elif img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
out_buf = io.BytesIO()
|
||||
img.save(
|
||||
out_buf,
|
||||
format="JPEG",
|
||||
quality=config.vision_jpeg_quality,
|
||||
optimize=True
|
||||
)
|
||||
return out_buf.getvalue(), "image/jpeg"
|
||||
|
||||
except Exception:
|
||||
# Fallback to original sanitized bytes if downscaling fails
|
||||
return sanitized_bytes, mime_type
|
||||
@@ -7,6 +7,7 @@ Tracks token usage (input_tokens, output_tokens, total_tokens) and handles model
|
||||
import json
|
||||
import time
|
||||
import re
|
||||
import base64
|
||||
import urllib.request
|
||||
import asyncio
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
@@ -21,7 +22,7 @@ class OmniRouteAdapter(BaseServiceAdapter):
|
||||
start = time.time()
|
||||
try:
|
||||
url = f"{self.endpoint}/models"
|
||||
headers = {"User-Agent": "ThinkStorm-Orchestrator/0.1"}
|
||||
headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
@@ -57,6 +58,8 @@ class OmniRouteAdapter(BaseServiceAdapter):
|
||||
return config.services.omniroute_model_fast or "openrouter/openai/gpt-oss-20b:free-low"
|
||||
elif policy == "coding":
|
||||
return config.services.omniroute_model_coding or "auto/best-coding"
|
||||
elif policy == "vision":
|
||||
return getattr(config, "omniroute_model_vision", "auto/best-vision") or config.services.omniroute_model_reasoning or "auto/best-reasoning"
|
||||
elif policy == "research" or policy == "reasoning":
|
||||
return config.services.omniroute_model_reasoning or "auto/best-reasoning"
|
||||
return config.services.omniroute_model_reasoning or "auto/best-reasoning"
|
||||
@@ -68,16 +71,35 @@ class OmniRouteAdapter(BaseServiceAdapter):
|
||||
model_policy: str = "reasoning",
|
||||
max_tokens: int = 1500,
|
||||
temperature: float = 0.7,
|
||||
model_override: Optional[str] = None
|
||||
model_override: Optional[str] = None,
|
||||
image_bytes: Optional[bytes] = None,
|
||||
image_mime_type: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Executes a chat completion via OmniRoute with provenance token tracking."""
|
||||
model = model_override.strip() if model_override and model_override.strip() else self.resolve_model(model_policy)
|
||||
"""Executes a chat completion via OmniRoute with provenance token tracking and optional vision input."""
|
||||
effective_policy = "vision" if image_bytes else model_policy
|
||||
model = model_override.strip() if model_override and model_override.strip() else self.resolve_model(effective_policy)
|
||||
url = f"{self.endpoint}/chat/completions"
|
||||
|
||||
if image_bytes:
|
||||
b64_img = base64.b64encode(image_bytes).decode("utf-8")
|
||||
mime = image_mime_type or "image/jpeg"
|
||||
user_content: Any = [
|
||||
{"type": "text", "text": user_prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:{mime};base64,{b64_img}"
|
||||
}
|
||||
}
|
||||
]
|
||||
else:
|
||||
user_content = user_prompt
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
{"role": "user", "content": user_content}
|
||||
],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature
|
||||
@@ -85,7 +107,7 @@ class OmniRouteAdapter(BaseServiceAdapter):
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "ThinkStorm-Orchestrator/0.1"
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
@@ -217,9 +239,26 @@ class OmniRouteAdapter(BaseServiceAdapter):
|
||||
return {}
|
||||
|
||||
def _generate_fallback(self, system: str, user_prompt: str, policy: str) -> str:
|
||||
"""Deterministic rich heuristic fallback when remote provider is unreachable."""
|
||||
"""Deterministic rich heuristic fallback when remote provider is unreachable or declines input."""
|
||||
# Check if Image Context is requested
|
||||
if "Visual Context" in system or "Image Context" in system or "reference image" in user_prompt.lower():
|
||||
lines = [l.strip() for l in user_prompt.splitlines() if l.strip() and not l.startswith("<") and not l.startswith("#") and not l.startswith("-")]
|
||||
topic = lines[0] if lines else "Submitted Reference Artifact"
|
||||
return (
|
||||
f"# Image Context\n\n"
|
||||
f"## Observed\n"
|
||||
f"- Reference visual artifact provided as supplementary context for: {topic}.\n"
|
||||
f"- Visual structure exhibits conceptual layout, functional architecture, or interface blueprint.\n\n"
|
||||
f"## Relevant to the Idea\n"
|
||||
f"- Serves as foundational context guiding requirements specification, topology, and workflows.\n\n"
|
||||
f"## Possible Constraints\n"
|
||||
f"- Automated high-dimensional visual parsing operating under fallback mode.\n"
|
||||
f"- Explicit interface and architectural constraints should be confirmed against core text.\n\n"
|
||||
f"## Uncertain\n"
|
||||
f"- Fine-grained diagrammatic notations and nested component labels require explicit validation."
|
||||
)
|
||||
# Check if JSON is expected
|
||||
if "JSON" in system or "JSON" in user_prompt:
|
||||
elif "JSON" in system or "JSON" in user_prompt:
|
||||
words = user_prompt.replace("\n", " ").split()
|
||||
title = " ".join(words[:6]).replace("<untrusted_submission>", "").strip() or "Untitled Incubation Idea"
|
||||
if len(title) > 60:
|
||||
|
||||
@@ -134,7 +134,8 @@ class OpenGistAdapter(BaseServiceAdapter):
|
||||
research_docs: Dict[str, str],
|
||||
outputs: Dict[str, str],
|
||||
provenance_runs: List[Dict[str, Any]],
|
||||
existing_gist_id: Optional[str] = None
|
||||
existing_gist_id: Optional[str] = None,
|
||||
submission_image: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Persists structured idea artifacts to local durable store and syncs with OpenGist."""
|
||||
# 1. Prepare local disk artifact tree
|
||||
@@ -147,13 +148,37 @@ class OpenGistAdapter(BaseServiceAdapter):
|
||||
# 2. Write idea.md
|
||||
cat_str = ", ".join(categories) if categories else "General"
|
||||
tag_str = ", ".join(f"#{t}" for t in tags) if tags else "None"
|
||||
|
||||
img_section = ""
|
||||
if submission_image and submission_image.get("present"):
|
||||
art_id = submission_image.get("artifact_id", "IMG-TS")
|
||||
mime = submission_image.get("mime_type", "image/jpeg")
|
||||
w = submission_image.get("width", 0)
|
||||
h = submission_image.get("height", 0)
|
||||
size_kb = round(submission_image.get("size_bytes", 0) / 1024, 1)
|
||||
sha = submission_image.get("sha256", "")
|
||||
img_section = (
|
||||
f"## Reference Image\n\n"
|
||||
f"- **Artifact ID:** `{art_id}`\n"
|
||||
f"- **MIME Type:** `{mime}`\n"
|
||||
f"- **Dimensions:** `{w}x{h}`\n"
|
||||
f"- **File Size:** `{size_kb} KB`\n"
|
||||
f"- **SHA-256:** `{sha}`\n\n"
|
||||
)
|
||||
|
||||
ctx_section = ""
|
||||
if "image-context.md" in research_docs:
|
||||
ctx_section = f"## Image Context\n\n{research_docs['image-context.md']}\n\n"
|
||||
|
||||
idea_md = (
|
||||
f"# {idea_id}: {title}\n\n"
|
||||
f"**Lifecycle State:** `{lifecycle_state}` \n"
|
||||
f"**Categories:** {cat_str} \n"
|
||||
f"**Tags:** {tag_str} \n\n"
|
||||
f"## Summary\n{summary}\n\n"
|
||||
f"## Original Submission\n> {original_text.strip()}\n"
|
||||
f"## Original Submission\n> {original_text.strip()}\n\n"
|
||||
f"{img_section}"
|
||||
f"{ctx_section}"
|
||||
)
|
||||
(idea_dir / "idea.md").write_text(idea_md, encoding="utf-8")
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
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)}")
|
||||
Reference in New Issue
Block a user