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:
@@ -22,6 +22,7 @@ from ..services.perplexica import PerplexicaAdapter
|
||||
from ..services.opengist import OpenGistAdapter
|
||||
from ..services.gitea import GiteaAdapter
|
||||
from ..services.virustotal import VirusTotalAdapter
|
||||
from ..services.signal_gateway import SignalGatewayAdapter
|
||||
from ..queue.worker import job_queue
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
@@ -32,6 +33,7 @@ perplexica_svc = PerplexicaAdapter()
|
||||
opengist_svc = OpenGistAdapter()
|
||||
gitea_svc = GiteaAdapter()
|
||||
virustotal_svc = VirusTotalAdapter()
|
||||
signal_gateway_svc = SignalGatewayAdapter()
|
||||
|
||||
class PromptUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
@@ -151,6 +153,8 @@ async def test_service_connection(service_id: str):
|
||||
adapter = perplexica_svc
|
||||
elif service_id == "virustotal":
|
||||
adapter = virustotal_svc
|
||||
elif service_id == "signal_gateway":
|
||||
adapter = signal_gateway_svc
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"Service '{service_id}' not found.")
|
||||
|
||||
@@ -247,7 +251,7 @@ async def list_jobs():
|
||||
async def retry_idea_job(idea_id: str):
|
||||
with get_db() as conn:
|
||||
conn.execute("UPDATE ideas SET processing_state = 'QUEUED' WHERE id = ?", (idea_id,))
|
||||
await job_queue.enqueue_foreground("intake", idea_id)
|
||||
await job_queue.enqueue_foreground("intake", idea_id, {"bypass_duplicate_check": True})
|
||||
return {"message": f"Idea {idea_id} enqueued for processing retry."}
|
||||
|
||||
# ----------------- Moderation & Quarantine -----------------
|
||||
|
||||
@@ -4,6 +4,7 @@ Handles login, logout, current user session status, and Gitea OAuth2 flow.
|
||||
"""
|
||||
|
||||
import json
|
||||
import requests
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Optional, Dict, Any
|
||||
@@ -111,41 +112,36 @@ async def gitea_oauth_callback(request: Request, code: str = "", error: str = ""
|
||||
if client_secret and client_id:
|
||||
try:
|
||||
token_url = f"{config.services.gitea_url}/login/oauth/access_token"
|
||||
data = urllib.parse.urlencode({
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
token_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "ThinkStorm-Orchestrator/0.1"
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10.0) as resp:
|
||||
resp_bytes = resp.read()
|
||||
}
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "ThinkStorm-Orchestrator/0.1"
|
||||
}
|
||||
resp = requests.post(token_url, data=payload, headers=headers, timeout=10.0)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
token_data = json.loads(resp_bytes.decode("utf-8"))
|
||||
token_data = resp.json()
|
||||
except Exception:
|
||||
token_data = urllib.parse.parse_qs(resp_bytes.decode("utf-8"))
|
||||
token_data = urllib.parse.parse_qs(resp.text)
|
||||
token_data = {k: v[0] for k, v in token_data.items()}
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
if access_token:
|
||||
user_info = await gitea_svc.verify_oauth_user(access_token)
|
||||
else:
|
||||
print(f"[Gitea OAuth] Token exchange returned status {resp.status_code}: {resp.text[:150]}")
|
||||
except Exception as e:
|
||||
print(f"[Gitea OAuth] Token exchange error: {e}")
|
||||
|
||||
# Fallback to default Gitea session if exchange failed
|
||||
if not user_info:
|
||||
user_info = {"id": 1001, "login": "gitea-user", "is_admin": False}
|
||||
print("[Gitea OAuth] Could not fetch valid user profile from Gitea OAuth access token.")
|
||||
return RedirectResponse(url="/login?error=oauth_verify_failed", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
user = get_or_create_gitea_user(user_info)
|
||||
token = create_session(user.id, user.username, user.role.value)
|
||||
|
||||
+147
-8
@@ -7,6 +7,7 @@ import json
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import APIRouter, Request, HTTPException, status, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import config
|
||||
@@ -19,6 +20,12 @@ from ..auth import get_current_user, require_authenticated, require_admin
|
||||
from ..queue.worker import job_queue
|
||||
from ..services.gitea import GiteaAdapter
|
||||
from ..services.opengist import OpenGistAdapter
|
||||
from ..services.image_handler import (
|
||||
validate_and_sanitize_image,
|
||||
save_image_artifact,
|
||||
get_image_artifact_path,
|
||||
ImageValidationError
|
||||
)
|
||||
from ..prompts.catalog import get_all_prompts
|
||||
|
||||
router = APIRouter(prefix="/api/ideas", tags=["ideas"])
|
||||
@@ -43,15 +50,48 @@ class WorkTrackActivateRequest(BaseModel):
|
||||
model_override: Optional[str] = None
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def submit_idea(payload: IdeaSubmissionRequest, request: Request):
|
||||
async def submit_idea(request: Request):
|
||||
"""
|
||||
Public Anonymous Idea Submission.
|
||||
- Zero authentication required
|
||||
- Supports JSON body ({"text": "..."}) and multipart/form-data (text + optional image)
|
||||
- Single image accepted, sanitized (EXIF/GPS stripped), and preserved as artifact
|
||||
- Automatic TS-xxxx ID assignment
|
||||
- Immutable original text preservation
|
||||
- Rate-limiting & abuse prevention
|
||||
"""
|
||||
raw_text = payload.text.strip()
|
||||
content_type = request.headers.get("content-type", "")
|
||||
raw_text = ""
|
||||
image_bytes = None
|
||||
image_filename = ""
|
||||
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
body = await request.json()
|
||||
if isinstance(body, dict):
|
||||
raw_text = str(body.get("text") or "")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Malformed JSON body.")
|
||||
elif "multipart/form-data" in content_type or "application/x-www-form-urlencoded" in content_type:
|
||||
try:
|
||||
form = await request.form()
|
||||
raw_text = str(form.get("text") or "")
|
||||
img_item = form.get("image") or form.get("file")
|
||||
if img_item and hasattr(img_item, "read"):
|
||||
image_bytes = await img_item.read()
|
||||
image_filename = getattr(img_item, "filename", "")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid form data: {str(e)}")
|
||||
else:
|
||||
# Fallback attempt JSON
|
||||
try:
|
||||
body = await request.json()
|
||||
if isinstance(body, dict):
|
||||
raw_text = str(body.get("text") or "")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid submission request.")
|
||||
|
||||
raw_text = raw_text.strip()
|
||||
if not raw_text:
|
||||
raise HTTPException(status_code=400, detail="Submission text cannot be empty.")
|
||||
if len(raw_text) > config.max_submission_chars:
|
||||
@@ -72,16 +112,33 @@ async def submit_idea(payload: IdeaSubmissionRequest, request: Request):
|
||||
idea_id = next_sequence("idea")
|
||||
now_iso = get_utc_now()
|
||||
|
||||
submission_image_meta = None
|
||||
if image_bytes and len(image_bytes) > 0:
|
||||
try:
|
||||
sanitized_bytes, submission_image_meta = validate_and_sanitize_image(
|
||||
raw_bytes=image_bytes,
|
||||
original_filename=image_filename,
|
||||
source="web",
|
||||
idea_id=idea_id
|
||||
)
|
||||
save_image_artifact(idea_id, sanitized_bytes, submission_image_meta)
|
||||
except ImageValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Image validation failed: {str(e)}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Could not process image upload: {str(e)}")
|
||||
|
||||
img_json = json.dumps(submission_image_meta) if submission_image_meta else None
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ideas (
|
||||
id, original_text, submitted_at, title, summary,
|
||||
lifecycle_state, processing_state, enrichment_level,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'SUBMITTED', 'QUEUED', 0, ?, ?)
|
||||
submission_image, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'SUBMITTED', 'QUEUED', 0, ?, ?, ?)
|
||||
""",
|
||||
(idea_id, raw_text, now_iso, "Processing New Idea...", "Analyzing submission text...", now_iso, now_iso)
|
||||
(idea_id, raw_text, now_iso, "Processing New Idea...", "Analyzing submission text...", img_json, now_iso, now_iso)
|
||||
)
|
||||
|
||||
# Enqueue intake pipeline for foreground triage
|
||||
@@ -92,7 +149,8 @@ async def submit_idea(payload: IdeaSubmissionRequest, request: Request):
|
||||
"lifecycle_state": "SUBMITTED",
|
||||
"processing_state": "QUEUED",
|
||||
"message": "Idea successfully accepted and queued for processing.",
|
||||
"submitted_at": now_iso
|
||||
"submitted_at": now_iso,
|
||||
"submission_image": submission_image_meta
|
||||
}
|
||||
|
||||
@router.get("")
|
||||
@@ -151,6 +209,13 @@ async def list_ideas(
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
results = []
|
||||
for r in rows:
|
||||
sub_img = None
|
||||
if "submission_image" in r.keys() and r["submission_image"]:
|
||||
try:
|
||||
sub_img = json.loads(r["submission_image"]) if isinstance(r["submission_image"], str) else r["submission_image"]
|
||||
except Exception:
|
||||
sub_img = None
|
||||
|
||||
results.append({
|
||||
"id": r["id"],
|
||||
"title": r["title"] or "Untitled Idea",
|
||||
@@ -161,7 +226,8 @@ async def list_ideas(
|
||||
"claimed_by": r["claimed_by"],
|
||||
"submitted_at": r["submitted_at"],
|
||||
"categories": [c.strip() for c in r["category_names"].split(",")] if r["category_names"] else [],
|
||||
"tags": [t.strip() for t in r["tag_names"].split(",")] if r["tag_names"] else []
|
||||
"tags": [t.strip() for t in r["tag_names"].split(",")] if r["tag_names"] else [],
|
||||
"submission_image": sub_img
|
||||
})
|
||||
return results
|
||||
|
||||
@@ -304,6 +370,14 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut
|
||||
for r in conn.execute("SELECT * FROM idea_relationships WHERE source_idea_id = ?", (idea_id,)).fetchall()
|
||||
]
|
||||
|
||||
# Image metadata
|
||||
sub_img = None
|
||||
if "submission_image" in idea_row.keys() and idea_row["submission_image"]:
|
||||
try:
|
||||
sub_img = json.loads(idea_row["submission_image"]) if isinstance(idea_row["submission_image"], str) else idea_row["submission_image"]
|
||||
except Exception:
|
||||
sub_img = None
|
||||
|
||||
return {
|
||||
"id": idea_row["id"],
|
||||
"title": idea_row["title"],
|
||||
@@ -321,6 +395,7 @@ async def get_idea_detail(idea_id: str, current_user: User = Depends(require_aut
|
||||
"profile_id": idea_row["profile_id"],
|
||||
"opengist_id": idea_row["opengist_id"],
|
||||
"opengist_url": idea_row["opengist_url"],
|
||||
"submission_image": sub_img,
|
||||
"categories": cats,
|
||||
"tags": tags,
|
||||
"urls": urls,
|
||||
@@ -726,6 +801,18 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
|
||||
elif stage == "FEASIBILITY":
|
||||
research_docs["feasibility.md"] = content
|
||||
|
||||
# Collect work track outputs
|
||||
outputs = {}
|
||||
track_rows = conn.execute("SELECT id, name FROM work_tracks WHERE idea_id = ?", (idea_id,)).fetchall()
|
||||
for tr in track_rows:
|
||||
tr_outputs = conn.execute(
|
||||
"SELECT name, content, version FROM work_track_outputs WHERE work_track_id = ? AND is_current = 1",
|
||||
(tr["id"],)
|
||||
).fetchall()
|
||||
for out in tr_outputs:
|
||||
track_slug = tr["name"].lower().replace(" ", "-")
|
||||
outputs[f"{track_slug}/{out['name']}"] = out["content"]
|
||||
|
||||
res = await gitea_svc.persist_idea_dossier_repo(
|
||||
idea_id=idea["id"],
|
||||
title=idea["title"] or "Untitled Idea",
|
||||
@@ -735,7 +822,7 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
|
||||
tags=tags,
|
||||
lifecycle_state=idea["lifecycle_state"],
|
||||
research_docs=research_docs,
|
||||
outputs={},
|
||||
outputs=outputs,
|
||||
provenance_runs=runs,
|
||||
existing_repo_url=idea.get("gitea_repo_url") if isinstance(idea, dict) else (idea["gitea_repo_url"] if "gitea_repo_url" in idea.keys() else None)
|
||||
)
|
||||
@@ -765,3 +852,55 @@ async def sync_idea_to_gitea(idea_id: str, current_user: User = Depends(get_curr
|
||||
"clone_url": res["clone_url"],
|
||||
"ssh_url": res["ssh_url"]
|
||||
}
|
||||
|
||||
@router.post("/{idea_id}/reprocess")
|
||||
async def reprocess_idea(
|
||||
idea_id: str,
|
||||
bypass_duplicate_check: bool = True,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Triggers end-to-end reprocessing of an idea (Prior Art, Deep Research, Feasibility, and Gitea Dossier)."""
|
||||
with get_db() as conn:
|
||||
idea = conn.execute("SELECT * FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
now = get_utc_now()
|
||||
conn.execute(
|
||||
"UPDATE ideas SET processing_state = 'QUEUED', updated_at = ? WHERE id = ?",
|
||||
(now, idea_id)
|
||||
)
|
||||
|
||||
await job_queue.enqueue_foreground("intake", idea_id, {"bypass_duplicate_check": bypass_duplicate_check})
|
||||
return {
|
||||
"message": f"Idea {idea_id} enqueued for processing.",
|
||||
"idea_id": idea_id,
|
||||
"processing_state": "QUEUED"
|
||||
}
|
||||
|
||||
@router.get("/{idea_id}/image")
|
||||
async def get_idea_image(idea_id: str):
|
||||
"""Serves the canonical reference image attached to an idea."""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("SELECT submission_image FROM ideas WHERE id = ?", (idea_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Idea not found.")
|
||||
|
||||
raw_img = row["submission_image"] if "submission_image" in row.keys() else None
|
||||
if not raw_img:
|
||||
raise HTTPException(status_code=404, detail="No reference image associated with this idea.")
|
||||
|
||||
meta = json.loads(raw_img) if isinstance(raw_img, str) else raw_img
|
||||
if not meta or not meta.get("present"):
|
||||
raise HTTPException(status_code=404, detail="No reference image associated with this idea.")
|
||||
|
||||
img_path = get_image_artifact_path(idea_id, meta)
|
||||
if not img_path or not img_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Image artifact file not found on disk.")
|
||||
|
||||
mime = meta.get("mime_type", "image/jpeg")
|
||||
return FileResponse(
|
||||
path=str(img_path),
|
||||
media_type=mime,
|
||||
filename=meta.get("original_filename", f"{idea_id}_reference_image")
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Signal Gateway Integration API Router
|
||||
Receives, authenticates, and durably processes inbound webhooks and events from Signal Gateway.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import secrets
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from fastapi import APIRouter, Request, HTTPException, status, Header
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..config import config
|
||||
from ..database import get_db, next_sequence, get_utc_now
|
||||
from ..queue.worker import job_queue
|
||||
from ..services.image_handler import (
|
||||
validate_and_sanitize_image,
|
||||
save_image_artifact,
|
||||
ImageValidationError
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/integrations/signal", tags=["signal_integration"])
|
||||
|
||||
def extract_signal_image_candidate(attachment: Any) -> Optional[Tuple[bytes, str]]:
|
||||
"""
|
||||
Extracts raw bytes and filename from a Signal attachment item.
|
||||
Returns (raw_bytes, filename) or None.
|
||||
"""
|
||||
if isinstance(attachment, dict):
|
||||
# 1. Base64 data payload
|
||||
b64_data = attachment.get("data") or attachment.get("base64") or attachment.get("bytes")
|
||||
filename = attachment.get("filename") or attachment.get("name") or "signal_attachment"
|
||||
if b64_data and isinstance(b64_data, str):
|
||||
try:
|
||||
if "," in b64_data:
|
||||
b64_data = b64_data.split(",", 1)[1]
|
||||
return base64.b64decode(b64_data), filename
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Filesystem path
|
||||
file_path_str = attachment.get("path") or attachment.get("file") or attachment.get("stored_filename")
|
||||
if file_path_str and isinstance(file_path_str, str):
|
||||
p = Path(file_path_str)
|
||||
if p.exists() and p.is_file():
|
||||
try:
|
||||
return p.read_bytes(), p.name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif isinstance(attachment, str):
|
||||
# Could be path or base64
|
||||
p = Path(attachment)
|
||||
if p.exists() and p.is_file():
|
||||
try:
|
||||
return p.read_bytes(), p.name
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
raw_str = attachment
|
||||
if "," in raw_str:
|
||||
raw_str = raw_str.split(",", 1)[1]
|
||||
return base64.b64decode(raw_str), "signal_attachment"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif isinstance(attachment, (bytes, bytearray)):
|
||||
return bytes(attachment), "signal_attachment"
|
||||
|
||||
return None
|
||||
|
||||
def get_configured_callback_secret() -> str:
|
||||
"""Retrieves configured callback bearer secret dynamically."""
|
||||
secret = config.services.signal_gateway_callback_secret or os.getenv("SIGNAL_GATEWAY_CALLBACK_SECRET", "")
|
||||
if secret:
|
||||
return secret
|
||||
# Try reloading .env in case it was updated after server startup
|
||||
from ..config import _load_env_file, BASE_DIR
|
||||
from pathlib import Path
|
||||
_load_env_file(Path("/root/.env"))
|
||||
_load_env_file(BASE_DIR / ".env")
|
||||
_load_env_file(BASE_DIR / "thinkstorm" / ".env")
|
||||
secret = os.getenv("SIGNAL_GATEWAY_CALLBACK_SECRET", "")
|
||||
if secret:
|
||||
config.services.signal_gateway_callback_secret = secret
|
||||
return secret
|
||||
try:
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT api_key_raw, config_json FROM service_configurations WHERE id = 'signal_gateway'"
|
||||
).fetchone()
|
||||
if row:
|
||||
conf = json.loads(row["config_json"] or "{}")
|
||||
if "callback_secret" in conf and conf["callback_secret"]:
|
||||
return conf["callback_secret"]
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def verify_callback_auth(request: Request):
|
||||
"""
|
||||
Enforces constant-time authentication comparison on the Bearer credential.
|
||||
Returns 401 on missing or invalid authentication without leaking details.
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Unauthorized"
|
||||
)
|
||||
|
||||
provided_token = auth_header[7:].strip()
|
||||
expected_secret = get_configured_callback_secret()
|
||||
|
||||
if not expected_secret or not provided_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Unauthorized"
|
||||
)
|
||||
|
||||
# Constant-time comparison to prevent timing attacks
|
||||
if not secrets.compare_digest(provided_token, expected_secret):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Unauthorized"
|
||||
)
|
||||
|
||||
@router.post("/events", status_code=status.HTTP_200_OK)
|
||||
async def handle_signal_events(
|
||||
request: Request,
|
||||
idempotency_key_header: Optional[str] = Header(None, alias="Idempotency-Key")
|
||||
):
|
||||
"""
|
||||
Inbound Signal Gateway Webhook Callback.
|
||||
Handles both test probes and actual normalized Signal messages.
|
||||
"""
|
||||
# 1. Authenticate Request
|
||||
verify_callback_auth(request)
|
||||
|
||||
# 2. Parse Raw JSON Body
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Malformed JSON body."
|
||||
)
|
||||
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid JSON structure."
|
||||
)
|
||||
|
||||
# Check schema_version
|
||||
schema_version = body.get("schema_version")
|
||||
if schema_version != 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported schema_version: {schema_version}. Expected 1."
|
||||
)
|
||||
|
||||
# 3. Handle Callback Test Request
|
||||
event_type = body.get("event_type")
|
||||
if event_type == "signal_gateway.callback_test":
|
||||
# Quick 2xx response without triggering message processing or database side effects
|
||||
return {
|
||||
"accepted": True,
|
||||
"event_type": "signal_gateway.callback_test"
|
||||
}
|
||||
|
||||
# 4. Handle Actual Signal Event
|
||||
event_id = body.get("event_id")
|
||||
if not event_id or not isinstance(event_id, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing or invalid event_id."
|
||||
)
|
||||
|
||||
# Validate Idempotency-Key header against JSON event_id
|
||||
if not idempotency_key_header or idempotency_key_header.strip() != event_id.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing or mismatched Idempotency-Key header."
|
||||
)
|
||||
|
||||
channel = body.get("channel", "signal")
|
||||
if channel != "signal":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported channel: {channel}."
|
||||
)
|
||||
|
||||
sender_uuid = body.get("sender_uuid")
|
||||
if not sender_uuid or not isinstance(sender_uuid, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing or invalid sender_uuid."
|
||||
)
|
||||
|
||||
text = body.get("text")
|
||||
if text is None or not isinstance(text, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing or invalid text field."
|
||||
)
|
||||
|
||||
raw_text = text.strip()
|
||||
if not raw_text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Message text cannot be empty."
|
||||
)
|
||||
|
||||
sender_number = body.get("sender_number")
|
||||
sender_name = body.get("sender_name")
|
||||
group_id = body.get("group_id")
|
||||
message_id = body.get("message_id") or str(int(request.state.time() if hasattr(request.state, "time") else 0))
|
||||
reply_to = body.get("reply_to")
|
||||
received_at = body.get("received_at") or get_utc_now()
|
||||
now_iso = get_utc_now()
|
||||
|
||||
# 5. Durable Idempotency & Persistence
|
||||
is_duplicate = False
|
||||
idea_id = None
|
||||
|
||||
with get_db() as conn:
|
||||
# Check if event already exists
|
||||
existing = conn.execute(
|
||||
"SELECT event_id, processing_status, idea_id FROM signal_inbound_events WHERE event_id = ?",
|
||||
(event_id,)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
is_duplicate = True
|
||||
idea_id = existing["idea_id"]
|
||||
else:
|
||||
# Generate new Idea ID
|
||||
idea_id = next_sequence("idea")
|
||||
|
||||
# Process optional Signal attachments (One-Image Rule)
|
||||
accepted_image_meta = None
|
||||
attachments = body.get("attachments") or body.get("attachment") or []
|
||||
if not isinstance(attachments, list):
|
||||
attachments = [attachments]
|
||||
|
||||
for att in attachments:
|
||||
if not att:
|
||||
continue
|
||||
candidate = extract_signal_image_candidate(att)
|
||||
if not candidate:
|
||||
# Non-image or unreadable attachment -> ignore
|
||||
continue
|
||||
raw_img_bytes, filename = candidate
|
||||
if not raw_img_bytes:
|
||||
continue
|
||||
|
||||
try:
|
||||
sanitized_bytes, img_meta = validate_and_sanitize_image(
|
||||
raw_bytes=raw_img_bytes,
|
||||
original_filename=filename,
|
||||
source="signal",
|
||||
idea_id=idea_id
|
||||
)
|
||||
save_image_artifact(idea_id, sanitized_bytes, img_meta)
|
||||
accepted_image_meta = img_meta
|
||||
# Once first valid image is accepted, ignore all subsequent attachments!
|
||||
break
|
||||
except ImageValidationError:
|
||||
# Non-image or corrupted candidate -> ignore and check next attachment
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
img_json = json.dumps(accepted_image_meta) if accepted_image_meta else None
|
||||
|
||||
# Insert idea into ThinkStorm ideas table first (so foreign key in signal_inbound_events is satisfied)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ideas (
|
||||
id, original_text, submitted_at, title, summary,
|
||||
lifecycle_state, processing_state, enrichment_level,
|
||||
source_channel, source_sender_uuid, source_group_id, source_event_id,
|
||||
submission_image, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'SUBMITTED', 'QUEUED', 0, 'signal', ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
idea_id,
|
||||
raw_text,
|
||||
received_at,
|
||||
"Processing New Idea (Signal)...",
|
||||
"Analyzing inbound Signal submission text...",
|
||||
sender_uuid,
|
||||
group_id,
|
||||
event_id,
|
||||
img_json,
|
||||
now_iso,
|
||||
now_iso
|
||||
)
|
||||
)
|
||||
|
||||
# Persist inbound event record referencing the new idea
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO signal_inbound_events (
|
||||
event_id, channel, sender_uuid, sender_number, sender_name,
|
||||
group_id, message_id, reply_to, text, received_at,
|
||||
processing_status, idea_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?)
|
||||
""",
|
||||
(
|
||||
event_id, channel, sender_uuid, sender_number, sender_name,
|
||||
group_id, str(message_id), reply_to, raw_text, received_at,
|
||||
idea_id, now_iso
|
||||
)
|
||||
)
|
||||
|
||||
if is_duplicate:
|
||||
return {
|
||||
"accepted": True,
|
||||
"event_id": event_id,
|
||||
"idea_id": idea_id,
|
||||
"duplicate": True,
|
||||
"status": "already_processed"
|
||||
}
|
||||
|
||||
# 6. Enqueue Background Intake Pipeline
|
||||
await job_queue.enqueue_foreground("intake", idea_id)
|
||||
|
||||
return {
|
||||
"accepted": True,
|
||||
"event_id": event_id,
|
||||
"idea_id": idea_id,
|
||||
"status": "queued"
|
||||
}
|
||||
Reference in New Issue
Block a user