widget UX batch
Browse files- app/demo_ui/index.html +17 -1
- app/models.py +20 -0
- app/orchestrator.py +15 -0
- app/ratelimit.py +18 -0
- app/retention.py +4 -1
- app/routes/chat.py +273 -15
- app/routes/demo.py +40 -11
- app/schemas.py +8 -0
- app/static/widget.js +416 -13
- app/tenancy.py +3 -0
- app/vision.py +85 -0
- migrations/versions/0020_attachments.py +45 -0
- tests/routes/test_chat_stream.py +250 -0
- tests/routes/test_demo.py +67 -0
- tests/routes/test_killswitch.py +109 -0
- tests/routes/test_uploads.py +457 -0
- tests/test_ratelimit.py +24 -0
app/demo_ui/index.html
CHANGED
|
@@ -229,14 +229,23 @@ function setFormError(msg){
|
|
| 229 |
}
|
| 230 |
function clearFormError(){ document.getElementById("form-error").hidden = true; }
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
function startDemo(url, email){
|
| 233 |
clearFormError();
|
| 234 |
document.getElementById("expired-note").hidden = true;
|
| 235 |
var btn = document.getElementById("start-btn");
|
| 236 |
btn.disabled = true; btn.textContent = tr("Creando tu demo…");
|
|
|
|
|
|
|
|
|
|
| 237 |
fetch(API + "/demo/start", {
|
| 238 |
method: "POST",
|
| 239 |
-
headers:
|
| 240 |
body: JSON.stringify({ url: url, email: email || "" })
|
| 241 |
})
|
| 242 |
.then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
|
|
@@ -381,6 +390,13 @@ document.getElementById("copy-btn").addEventListener("click", function(){
|
|
| 381 |
document.addEventListener("DOMContentLoaded", function(){
|
| 382 |
var l = currentLang(); if (l === "en") applyLang("en");
|
| 383 |
var qs = new URLSearchParams(location.search);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
var d = qs.get("d");
|
| 385 |
var u = qs.get("url");
|
| 386 |
if (d) {
|
|
|
|
| 229 |
}
|
| 230 |
function clearFormError(){ document.getElementById("form-error").hidden = true; }
|
| 231 |
|
| 232 |
+
// Founder bypass: /demo?admin=<token> stores the token for this tab only and
|
| 233 |
+
// sends it as X-Admin-Token on /demo/start (the backend skips the caps).
|
| 234 |
+
function adminToken(){
|
| 235 |
+
try { return sessionStorage.getItem("demo_admin_token") || ""; } catch(e){ return ""; }
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
function startDemo(url, email){
|
| 239 |
clearFormError();
|
| 240 |
document.getElementById("expired-note").hidden = true;
|
| 241 |
var btn = document.getElementById("start-btn");
|
| 242 |
btn.disabled = true; btn.textContent = tr("Creando tu demo…");
|
| 243 |
+
var headers = { "Content-Type": "application/json" };
|
| 244 |
+
var admin = adminToken();
|
| 245 |
+
if (admin) headers["X-Admin-Token"] = admin;
|
| 246 |
fetch(API + "/demo/start", {
|
| 247 |
method: "POST",
|
| 248 |
+
headers: headers,
|
| 249 |
body: JSON.stringify({ url: url, email: email || "" })
|
| 250 |
})
|
| 251 |
.then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
|
|
|
|
| 390 |
document.addEventListener("DOMContentLoaded", function(){
|
| 391 |
var l = currentLang(); if (l === "en") applyLang("en");
|
| 392 |
var qs = new URLSearchParams(location.search);
|
| 393 |
+
var adm = qs.get("admin");
|
| 394 |
+
if (adm) {
|
| 395 |
+
try { sessionStorage.setItem("demo_admin_token", adm); } catch(e){}
|
| 396 |
+
qs.delete("admin");
|
| 397 |
+
var rest = qs.toString();
|
| 398 |
+
history.replaceState(null, "", location.pathname + (rest ? "?" + rest : ""));
|
| 399 |
+
}
|
| 400 |
var d = qs.get("d");
|
| 401 |
var u = qs.get("url");
|
| 402 |
if (d) {
|
app/models.py
CHANGED
|
@@ -275,6 +275,26 @@ class HandoffRequest(Base):
|
|
| 275 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 276 |
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
class ChatMessage(Base):
|
| 279 |
__tablename__ = "chat_messages"
|
| 280 |
|
|
|
|
| 275 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 276 |
|
| 277 |
|
| 278 |
+
class Attachment(Base):
|
| 279 |
+
"""A file a VISITOR uploaded mid-conversation (PDF or photo), already
|
| 280 |
+
converted to text (PDF extraction / vision description). The binary is
|
| 281 |
+
NEVER persisted (ephemeral disk on the free host) and the text is scoped
|
| 282 |
+
to exactly one tenant + one chat session: it is never embedded, never
|
| 283 |
+
written to the knowledge base, never visible to any other conversation."""
|
| 284 |
+
|
| 285 |
+
__tablename__ = "attachments"
|
| 286 |
+
|
| 287 |
+
id: Mapped[str] = mapped_column(String(40), primary_key=True, default=lambda: uuid.uuid4().hex)
|
| 288 |
+
tenant_id: Mapped[int | None] = mapped_column(
|
| 289 |
+
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True
|
| 290 |
+
)
|
| 291 |
+
session_id: Mapped[str] = mapped_column(String(40), index=True)
|
| 292 |
+
filename: Mapped[str] = mapped_column(String(255), default="")
|
| 293 |
+
mime: Mapped[str] = mapped_column(String(60), default="")
|
| 294 |
+
text: Mapped[str] = mapped_column(Text, default="")
|
| 295 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
class ChatMessage(Base):
|
| 299 |
__tablename__ = "chat_messages"
|
| 300 |
|
app/orchestrator.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
| 5 |
import json
|
| 6 |
import logging
|
| 7 |
import re
|
|
|
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
from sqlalchemy import select
|
|
@@ -46,6 +47,17 @@ NO_TOOLS_NOTE = (
|
|
| 46 |
)
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def _toolcall_to_openai(tc: ToolCall) -> dict[str, Any]:
|
| 50 |
return {
|
| 51 |
"id": tc.id,
|
|
@@ -72,7 +84,9 @@ async def run_turn(
|
|
| 72 |
*,
|
| 73 |
brand_name: str = "Asistente",
|
| 74 |
instructions: str = "",
|
|
|
|
| 75 |
) -> ChatResponse:
|
|
|
|
| 76 |
prior = await _load_history(ctx)
|
| 77 |
ctx.user_message = user_message # for the deterministic write-action confirm gate
|
| 78 |
|
|
@@ -157,6 +171,7 @@ async def run_turn(
|
|
| 157 |
)
|
| 158 |
for tc in result.tool_calls:
|
| 159 |
used_tools.append(tc.name)
|
|
|
|
| 160 |
tool_result = await registry.dispatch(tc.name, tc.arguments, ctx)
|
| 161 |
payload = json.dumps(tool_result, ensure_ascii=False, default=str)
|
| 162 |
if len(payload) > MAX_TOOL_RESULT_CHARS: # protect small-window models
|
|
|
|
| 5 |
import json
|
| 6 |
import logging
|
| 7 |
import re
|
| 8 |
+
from collections.abc import Callable
|
| 9 |
from typing import Any
|
| 10 |
|
| 11 |
from sqlalchemy import select
|
|
|
|
| 47 |
)
|
| 48 |
|
| 49 |
|
| 50 |
+
def _emit_stage(on_stage: Callable[[str], None] | None, key: str) -> None:
|
| 51 |
+
"""Best-effort UI progress signal. A broken callback must NEVER break a
|
| 52 |
+
chat turn, so every invocation is fully shielded."""
|
| 53 |
+
if on_stage is None:
|
| 54 |
+
return
|
| 55 |
+
try:
|
| 56 |
+
on_stage(key)
|
| 57 |
+
except Exception:
|
| 58 |
+
log.warning("on_stage callback failed for %r", key, exc_info=True)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
def _toolcall_to_openai(tc: ToolCall) -> dict[str, Any]:
|
| 62 |
return {
|
| 63 |
"id": tc.id,
|
|
|
|
| 84 |
*,
|
| 85 |
brand_name: str = "Asistente",
|
| 86 |
instructions: str = "",
|
| 87 |
+
on_stage: Callable[[str], None] | None = None,
|
| 88 |
) -> ChatResponse:
|
| 89 |
+
_emit_stage(on_stage, "thinking")
|
| 90 |
prior = await _load_history(ctx)
|
| 91 |
ctx.user_message = user_message # for the deterministic write-action confirm gate
|
| 92 |
|
|
|
|
| 171 |
)
|
| 172 |
for tc in result.tool_calls:
|
| 173 |
used_tools.append(tc.name)
|
| 174 |
+
_emit_stage(on_stage, "tool:" + tc.name)
|
| 175 |
tool_result = await registry.dispatch(tc.name, tc.arguments, ctx)
|
| 176 |
payload = json.dumps(tool_result, ensure_ascii=False, default=str)
|
| 177 |
if len(payload) > MAX_TOOL_RESULT_CHARS: # protect small-window models
|
app/ratelimit.py
CHANGED
|
@@ -30,6 +30,24 @@ class RateLimiter:
|
|
| 30 |
self._hits[key] = hits
|
| 31 |
return True
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def reset(self, key: str) -> None:
|
| 34 |
self._hits.pop(key, None)
|
| 35 |
|
|
|
|
| 30 |
self._hits[key] = hits
|
| 31 |
return True
|
| 32 |
|
| 33 |
+
def would_allow(self, key: str, now: datetime | None = None) -> bool:
|
| 34 |
+
"""Peek: like allow() but WITHOUT consuming quota. Lets callers gate a
|
| 35 |
+
request up front and only record() once the work actually succeeds, so
|
| 36 |
+
rejected attempts don't burn the caller's budget."""
|
| 37 |
+
ts = (now or datetime.now(UTC)).timestamp()
|
| 38 |
+
cutoff = ts - self.window
|
| 39 |
+
# .get(): peeking must not materialize defaultdict entries for keys
|
| 40 |
+
# that never get a recorded hit.
|
| 41 |
+
return len([h for h in self._hits.get(key, []) if h > cutoff]) < self.max_hits
|
| 42 |
+
|
| 43 |
+
def record(self, key: str, now: datetime | None = None) -> None:
|
| 44 |
+
"""Consume one slot unconditionally (pair with would_allow())."""
|
| 45 |
+
ts = (now or datetime.now(UTC)).timestamp()
|
| 46 |
+
cutoff = ts - self.window
|
| 47 |
+
hits = [h for h in self._hits[key] if h > cutoff]
|
| 48 |
+
hits.append(ts)
|
| 49 |
+
self._hits[key] = hits
|
| 50 |
+
|
| 51 |
def reset(self, key: str) -> None:
|
| 52 |
self._hits.pop(key, None)
|
| 53 |
|
app/retention.py
CHANGED
|
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
|
|
| 7 |
from sqlalchemy import delete, select
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
|
| 10 |
-
from app.models import ChatMessage, ChatSession, Event
|
| 11 |
|
| 12 |
|
| 13 |
async def purge_old_sessions(db: AsyncSession, days: int) -> int:
|
|
@@ -28,6 +28,9 @@ async def purge_old_sessions(db: AsyncSession, days: int) -> int:
|
|
| 28 |
await db.flush()
|
| 29 |
return 0
|
| 30 |
await db.execute(delete(ChatMessage).where(ChatMessage.session_id.in_(ids)))
|
|
|
|
|
|
|
|
|
|
| 31 |
await db.execute(delete(ChatSession).where(ChatSession.id.in_(ids)))
|
| 32 |
await db.flush()
|
| 33 |
return len(ids)
|
|
|
|
| 7 |
from sqlalchemy import delete, select
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
|
| 10 |
+
from app.models import Attachment, ChatMessage, ChatSession, Event
|
| 11 |
|
| 12 |
|
| 13 |
async def purge_old_sessions(db: AsyncSession, days: int) -> int:
|
|
|
|
| 28 |
await db.flush()
|
| 29 |
return 0
|
| 30 |
await db.execute(delete(ChatMessage).where(ChatMessage.session_id.in_(ids)))
|
| 31 |
+
# Visitor uploads die with their conversation (GDPR: they may contain
|
| 32 |
+
# personal documents, so they never outlive the session that owns them).
|
| 33 |
+
await db.execute(delete(Attachment).where(Attachment.session_id.in_(ids)))
|
| 34 |
await db.execute(delete(ChatSession).where(ChatSession.id.in_(ids)))
|
| 35 |
await db.flush()
|
| 36 |
return len(ids)
|
app/routes/chat.py
CHANGED
|
@@ -2,38 +2,69 @@
|
|
| 2 |
|
| 3 |
- POST /chat?t=<slug> — simple mode: public, CORS-enabled, no signature. The
|
| 4 |
backend-served widget uses this (one <script> to embed).
|
|
|
|
|
|
|
|
|
|
| 5 |
- POST /apps/chat — secure mode: behind the Shopify App Proxy (HMAC); tenant
|
| 6 |
resolved from the signed `shop` param (or ?t).
|
| 7 |
|
| 8 |
-
|
| 9 |
verification + per-email rate limiting regardless of entry point.
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from datetime import UTC, datetime
|
| 15 |
|
| 16 |
-
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 17 |
-
from
|
|
|
|
| 18 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 19 |
|
|
|
|
| 20 |
from app.config import Settings, get_settings
|
| 21 |
from app.db import get_session
|
| 22 |
from app.deps import get_router
|
| 23 |
from app.handoff import create_handoff
|
| 24 |
-
from app.models import Tenant
|
| 25 |
from app.orchestrator import run_turn
|
|
|
|
| 26 |
from app.retention import purge_old_sessions
|
| 27 |
from app.schemas import ChatRequest, ChatResponse, HandoffIn
|
| 28 |
from app.shopify.proxy import verify_proxy_signature
|
|
|
|
| 29 |
from app.store import get_or_create_session
|
| 30 |
from app.tenancy import DEFAULT_SLUG, get_tenant_by_slug, tenant_shopify_client
|
| 31 |
from app.tools.registry import ToolContext
|
| 32 |
from app.verification import mark_trusted
|
| 33 |
|
|
|
|
|
|
|
| 34 |
router = APIRouter()
|
| 35 |
|
| 36 |
PROXY_TIMESTAMP_TOLERANCE = 300 # seconds; reject replayed/stale signed requests
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
def _collect_params(request: Request) -> dict[str, object]:
|
|
@@ -80,27 +111,28 @@ async def _resolve_tenant(db: AsyncSession, slug: str | None, *, shop: str = "")
|
|
| 80 |
return tenant
|
| 81 |
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
*,
|
| 85 |
db: AsyncSession,
|
| 86 |
-
router_,
|
| 87 |
settings: Settings,
|
| 88 |
tenant: Tenant,
|
| 89 |
-
message: str,
|
| 90 |
session_id: str | None,
|
| 91 |
trusted: bool,
|
| 92 |
now: datetime,
|
| 93 |
-
) ->
|
| 94 |
-
if not tenant.chatbot_enabled:
|
| 95 |
-
# Admin turned this store's chatbot off (e.g. non-payment): refuse
|
| 96 |
-
# politely without spending an LLM call. The widget also self-hides.
|
| 97 |
-
raise HTTPException(status_code=403, detail="chatbot disabled for this store")
|
| 98 |
session = await get_or_create_session(
|
| 99 |
db, session_id, shop=tenant.shopify_shop, tenant_id=tenant.id
|
| 100 |
)
|
| 101 |
if trusted:
|
| 102 |
mark_trusted(session)
|
| 103 |
-
|
| 104 |
db=db,
|
| 105 |
session=session,
|
| 106 |
shopify=tenant_shopify_client(tenant),
|
|
@@ -113,8 +145,59 @@ async def _run_chat(
|
|
| 113 |
support_email=settings.support_email,
|
| 114 |
now=now,
|
| 115 |
)
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
await purge_old_sessions(db, settings.session_retention_days)
|
| 119 |
await db.commit()
|
| 120 |
return response
|
|
@@ -139,6 +222,181 @@ async def chat_simple(
|
|
| 139 |
session_id=body.session_id,
|
| 140 |
trusted=False,
|
| 141 |
now=datetime.now(UTC),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
)
|
| 143 |
|
| 144 |
|
|
|
|
| 2 |
|
| 3 |
- POST /chat?t=<slug> — simple mode: public, CORS-enabled, no signature. The
|
| 4 |
backend-served widget uses this (one <script> to embed).
|
| 5 |
+
- POST /chat/stream — same contract as /chat but answers as an SSE stream:
|
| 6 |
+
live "stage" progress events while the bot works, then
|
| 7 |
+
one "final" event with the complete ChatResponse JSON.
|
| 8 |
- POST /apps/chat — secure mode: behind the Shopify App Proxy (HMAC); tenant
|
| 9 |
resolved from the signed `shop` param (or ?t).
|
| 10 |
|
| 11 |
+
All share one core. Order disclosure always requires email+order#
|
| 12 |
verification + per-email rate limiting regardless of entry point.
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
+
import asyncio
|
| 18 |
+
import contextlib
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
from collections.abc import AsyncIterator
|
| 22 |
from datetime import UTC, datetime
|
| 23 |
|
| 24 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
| 25 |
+
from fastapi.responses import StreamingResponse
|
| 26 |
+
from sqlalchemy import func, select
|
| 27 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 28 |
|
| 29 |
+
from app import vision
|
| 30 |
from app.config import Settings, get_settings
|
| 31 |
from app.db import get_session
|
| 32 |
from app.deps import get_router
|
| 33 |
from app.handoff import create_handoff
|
| 34 |
+
from app.models import Attachment, Tenant
|
| 35 |
from app.orchestrator import run_turn
|
| 36 |
+
from app.rag import extract
|
| 37 |
from app.retention import purge_old_sessions
|
| 38 |
from app.schemas import ChatRequest, ChatResponse, HandoffIn
|
| 39 |
from app.shopify.proxy import verify_proxy_signature
|
| 40 |
+
from app.sources import SAFE_NAME
|
| 41 |
from app.store import get_or_create_session
|
| 42 |
from app.tenancy import DEFAULT_SLUG, get_tenant_by_slug, tenant_shopify_client
|
| 43 |
from app.tools.registry import ToolContext
|
| 44 |
from app.verification import mark_trusted
|
| 45 |
|
| 46 |
+
log = logging.getLogger(__name__)
|
| 47 |
+
|
| 48 |
router = APIRouter()
|
| 49 |
|
| 50 |
PROXY_TIMESTAMP_TOLERANCE = 300 # seconds; reject replayed/stale signed requests
|
| 51 |
+
STREAM_PING_SECONDS = 10.0 # SSE keep-alive comment cadence while the turn runs
|
| 52 |
+
|
| 53 |
+
# --- visitor uploads (Phase 3) ---------------------------------------------
|
| 54 |
+
# Binaries are NEVER persisted (ephemeral disk): a PDF becomes its extracted
|
| 55 |
+
# text and a photo becomes a vision description, stored per tenant + session.
|
| 56 |
+
MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 # hard cap, enforced while reading
|
| 57 |
+
MAX_ATTACHMENTS_PER_SESSION = 3
|
| 58 |
+
ATTACHMENT_TEXT_CAP = 6000 # chars stored per attachment
|
| 59 |
+
ATTACHMENT_BLOCK_CAP = 12000 # chars of attachment context prepended per turn
|
| 60 |
+
VISION_FALLBACK_TEXT = "(imagen recibida pero no se ha podido analizar)"
|
| 61 |
+
|
| 62 |
+
MSG_TOO_MANY_ATTACHMENTS = "Solo se pueden adjuntar 3 archivos por conversación."
|
| 63 |
+
MSG_ATTACHMENT_TOO_BIG = "El archivo supera el tamaño máximo de 5 MB."
|
| 64 |
+
MSG_BAD_ATTACHMENT_TYPE = (
|
| 65 |
+
"Tipo de archivo no admitido. Adjunta un PDF o una imagen (PNG, JPG o WEBP)."
|
| 66 |
+
)
|
| 67 |
+
MSG_PDF_NO_TEXT = "El PDF no tiene texto legible."
|
| 68 |
|
| 69 |
|
| 70 |
def _collect_params(request: Request) -> dict[str, object]:
|
|
|
|
| 111 |
return tenant
|
| 112 |
|
| 113 |
|
| 114 |
+
def _ensure_chatbot_enabled(tenant: Tenant) -> None:
|
| 115 |
+
if not tenant.chatbot_enabled:
|
| 116 |
+
# Admin turned this store's chatbot off (e.g. non-payment): refuse
|
| 117 |
+
# politely without spending an LLM call. The widget also self-hides.
|
| 118 |
+
raise HTTPException(status_code=403, detail="chatbot disabled for this store")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
async def _make_context(
|
| 122 |
*,
|
| 123 |
db: AsyncSession,
|
|
|
|
| 124 |
settings: Settings,
|
| 125 |
tenant: Tenant,
|
|
|
|
| 126 |
session_id: str | None,
|
| 127 |
trusted: bool,
|
| 128 |
now: datetime,
|
| 129 |
+
) -> ToolContext:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
session = await get_or_create_session(
|
| 131 |
db, session_id, shop=tenant.shopify_shop, tenant_id=tenant.id
|
| 132 |
)
|
| 133 |
if trusted:
|
| 134 |
mark_trusted(session)
|
| 135 |
+
return ToolContext(
|
| 136 |
db=db,
|
| 137 |
session=session,
|
| 138 |
shopify=tenant_shopify_client(tenant),
|
|
|
|
| 145 |
support_email=settings.support_email,
|
| 146 |
now=now,
|
| 147 |
)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def _attachment_block(
|
| 151 |
+
db: AsyncSession, tenant_id: int, session_id: str, attachment_ids: list[str]
|
| 152 |
+
) -> str:
|
| 153 |
+
"""Context block for the attachments referenced by THIS turn.
|
| 154 |
+
|
| 155 |
+
Strict isolation invariant (the product's #1 requirement): an id only
|
| 156 |
+
matches when it belongs to the SAME tenant AND the SAME chat session.
|
| 157 |
+
Any other id — another tenant's, another session's, or simply unknown —
|
| 158 |
+
is SILENTLY dropped: never an error, so existence is never revealed.
|
| 159 |
+
"""
|
| 160 |
+
if not attachment_ids:
|
| 161 |
+
return ""
|
| 162 |
+
rows = (
|
| 163 |
+
await db.execute(
|
| 164 |
+
select(Attachment)
|
| 165 |
+
.where(
|
| 166 |
+
Attachment.id.in_(attachment_ids),
|
| 167 |
+
Attachment.tenant_id == tenant_id,
|
| 168 |
+
Attachment.session_id == session_id,
|
| 169 |
+
)
|
| 170 |
+
.order_by(Attachment.created_at, Attachment.id)
|
| 171 |
+
)
|
| 172 |
+
).scalars().all()
|
| 173 |
+
parts = [f"[El cliente ha adjuntado {a.filename}]:\n{a.text}\n\n" for a in rows]
|
| 174 |
+
return "".join(parts)[:ATTACHMENT_BLOCK_CAP]
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
async def _run_chat(
|
| 178 |
+
*,
|
| 179 |
+
db: AsyncSession,
|
| 180 |
+
router_,
|
| 181 |
+
settings: Settings,
|
| 182 |
+
tenant: Tenant,
|
| 183 |
+
message: str,
|
| 184 |
+
session_id: str | None,
|
| 185 |
+
trusted: bool,
|
| 186 |
+
now: datetime,
|
| 187 |
+
on_stage=None,
|
| 188 |
+
attachment_ids: list[str] | None = None,
|
| 189 |
+
) -> ChatResponse:
|
| 190 |
+
_ensure_chatbot_enabled(tenant)
|
| 191 |
+
ctx = await _make_context(
|
| 192 |
+
db=db, settings=settings, tenant=tenant,
|
| 193 |
+
session_id=session_id, trusted=trusted, now=now,
|
| 194 |
+
)
|
| 195 |
+
# Attachments ride INSIDE the user message (also persisted that way, so
|
| 196 |
+
# the conversation history replays coherently). They are never embedded
|
| 197 |
+
# nor written to the knowledge base.
|
| 198 |
+
block = await _attachment_block(db, tenant.id, ctx.session.id, attachment_ids or [])
|
| 199 |
+
response = await run_turn(router_, ctx, block + message, brand_name=tenant.brand_name,
|
| 200 |
+
instructions=tenant.custom_instructions, on_stage=on_stage)
|
| 201 |
await purge_old_sessions(db, settings.session_retention_days)
|
| 202 |
await db.commit()
|
| 203 |
return response
|
|
|
|
| 222 |
session_id=body.session_id,
|
| 223 |
trusted=False,
|
| 224 |
now=datetime.now(UTC),
|
| 225 |
+
attachment_ids=body.attachment_ids,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _sniff_attachment(data: bytes) -> tuple[str, str] | None:
|
| 230 |
+
"""(kind, mime) by MAGIC BYTES — extension and Content-Type are attacker
|
| 231 |
+
controlled and ignored. Only PDF, PNG, JPEG and WEBP are accepted."""
|
| 232 |
+
if data.startswith(b"%PDF"):
|
| 233 |
+
return ("pdf", "application/pdf")
|
| 234 |
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
| 235 |
+
return ("image", "image/png")
|
| 236 |
+
if data.startswith(b"\xff\xd8\xff"):
|
| 237 |
+
return ("image", "image/jpeg")
|
| 238 |
+
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
| 239 |
+
return ("image", "image/webp")
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
@router.post("/chat/upload")
|
| 244 |
+
async def chat_upload(
|
| 245 |
+
request: Request,
|
| 246 |
+
file: UploadFile = File(...),
|
| 247 |
+
session_id: str | None = Form(None),
|
| 248 |
+
db: AsyncSession = Depends(get_session),
|
| 249 |
+
) -> dict:
|
| 250 |
+
"""Visitor mid-conversation upload (PDF or photo). Same gates as /chat —
|
| 251 |
+
unknown explicit slug 404, paused tenant 403 — and the attachment binds to
|
| 252 |
+
exactly the session the next chat message will use. The binary is turned
|
| 253 |
+
into text here (PDF extraction / vision description) and discarded."""
|
| 254 |
+
tenant = await _resolve_tenant(db, request.query_params.get("t"))
|
| 255 |
+
_ensure_chatbot_enabled(tenant)
|
| 256 |
+
session = await get_or_create_session(
|
| 257 |
+
db, session_id, shop=tenant.shopify_shop, tenant_id=tenant.id
|
| 258 |
+
)
|
| 259 |
+
existing = (
|
| 260 |
+
await db.execute(
|
| 261 |
+
select(func.count()).select_from(Attachment).where(
|
| 262 |
+
Attachment.tenant_id == tenant.id,
|
| 263 |
+
Attachment.session_id == session.id,
|
| 264 |
+
)
|
| 265 |
+
)
|
| 266 |
+
).scalar_one()
|
| 267 |
+
if existing >= MAX_ATTACHMENTS_PER_SESSION:
|
| 268 |
+
raise HTTPException(status_code=422, detail=MSG_TOO_MANY_ATTACHMENTS)
|
| 269 |
+
|
| 270 |
+
# Size cap enforced WHILE reading: an oversized body is rejected as soon
|
| 271 |
+
# as it crosses the limit, without buffering the whole thing.
|
| 272 |
+
buf = bytearray()
|
| 273 |
+
while True:
|
| 274 |
+
chunk = await file.read(1024 * 1024)
|
| 275 |
+
if not chunk:
|
| 276 |
+
break
|
| 277 |
+
buf.extend(chunk)
|
| 278 |
+
if len(buf) > MAX_ATTACHMENT_BYTES:
|
| 279 |
+
raise HTTPException(status_code=413, detail=MSG_ATTACHMENT_TOO_BIG)
|
| 280 |
+
data = bytes(buf)
|
| 281 |
+
|
| 282 |
+
sniffed = _sniff_attachment(data)
|
| 283 |
+
if sniffed is None:
|
| 284 |
+
raise HTTPException(status_code=422, detail=MSG_BAD_ATTACHMENT_TYPE)
|
| 285 |
+
kind, mime = sniffed
|
| 286 |
+
|
| 287 |
+
if kind == "pdf":
|
| 288 |
+
try:
|
| 289 |
+
# Same extractor as the knowledge pipeline (app/rag/extract.py).
|
| 290 |
+
text = await asyncio.to_thread(extract.extract_bytes, data, "pdf")
|
| 291 |
+
except Exception: # noqa: BLE001 - corrupt/unreadable PDF -> friendly 422
|
| 292 |
+
log.warning("attachment pdf extraction failed", exc_info=True)
|
| 293 |
+
text = ""
|
| 294 |
+
text = (text or "").strip()[:ATTACHMENT_TEXT_CAP]
|
| 295 |
+
if not text:
|
| 296 |
+
raise HTTPException(status_code=422, detail=MSG_PDF_NO_TEXT)
|
| 297 |
+
else:
|
| 298 |
+
described = await vision.describe_image(data, mime)
|
| 299 |
+
text = (described or "").strip()[:ATTACHMENT_TEXT_CAP] or VISION_FALLBACK_TEXT
|
| 300 |
+
|
| 301 |
+
safe_name = SAFE_NAME.sub("_", file.filename or "archivo")[:255]
|
| 302 |
+
if safe_name in (".", "..", ""):
|
| 303 |
+
safe_name = "archivo"
|
| 304 |
+
att = Attachment(
|
| 305 |
+
tenant_id=tenant.id, session_id=session.id,
|
| 306 |
+
filename=safe_name, mime=mime, text=text,
|
| 307 |
+
)
|
| 308 |
+
db.add(att)
|
| 309 |
+
await db.flush()
|
| 310 |
+
out = {
|
| 311 |
+
"attachment_id": att.id,
|
| 312 |
+
"filename": safe_name,
|
| 313 |
+
"kind": kind,
|
| 314 |
+
"preview": text[:200],
|
| 315 |
+
# The widget MUST adopt this id so the next message lands on the same
|
| 316 |
+
# session the attachment is bound to (first contact may be an upload).
|
| 317 |
+
"session_id": session.id,
|
| 318 |
+
}
|
| 319 |
+
await db.commit()
|
| 320 |
+
return out
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _sse(event: str, data: str) -> str:
|
| 324 |
+
return f"event: {event}\ndata: {data}\n\n"
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
async def _stream_events(
|
| 328 |
+
*,
|
| 329 |
+
db: AsyncSession,
|
| 330 |
+
router_,
|
| 331 |
+
settings: Settings,
|
| 332 |
+
tenant: Tenant,
|
| 333 |
+
message: str,
|
| 334 |
+
session_id: str | None,
|
| 335 |
+
attachment_ids: list[str] | None = None,
|
| 336 |
+
) -> AsyncIterator[str]:
|
| 337 |
+
"""Yield SSE frames: live 'stage' events while run_turn works, then one
|
| 338 |
+
'final' event carrying the complete ChatResponse JSON (same shape as
|
| 339 |
+
POST /chat). On failure: an 'error' event (the widget falls back to /chat)."""
|
| 340 |
+
queue: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue()
|
| 341 |
+
|
| 342 |
+
def on_stage(key: str) -> None:
|
| 343 |
+
queue.put_nowait(("stage", json.dumps({"key": key})))
|
| 344 |
+
|
| 345 |
+
async def _runner() -> None:
|
| 346 |
+
try:
|
| 347 |
+
response = await _run_chat(
|
| 348 |
+
db=db, router_=router_, settings=settings, tenant=tenant,
|
| 349 |
+
message=message, session_id=session_id, trusted=False,
|
| 350 |
+
now=datetime.now(UTC), on_stage=on_stage,
|
| 351 |
+
attachment_ids=attachment_ids,
|
| 352 |
+
)
|
| 353 |
+
queue.put_nowait(("final", response.model_dump_json()))
|
| 354 |
+
except Exception:
|
| 355 |
+
log.exception("chat stream turn failed tenant=%s", tenant.slug)
|
| 356 |
+
queue.put_nowait(("error", json.dumps({"detail": "stream failed"})))
|
| 357 |
+
finally:
|
| 358 |
+
queue.put_nowait(None) # sentinel: nothing more will arrive
|
| 359 |
+
|
| 360 |
+
task = asyncio.create_task(_runner())
|
| 361 |
+
try:
|
| 362 |
+
while True:
|
| 363 |
+
try:
|
| 364 |
+
item = await asyncio.wait_for(queue.get(), timeout=STREAM_PING_SECONDS)
|
| 365 |
+
except TimeoutError:
|
| 366 |
+
yield ": ping\n\n" # keep proxies from killing the idle stream
|
| 367 |
+
continue
|
| 368 |
+
if item is None:
|
| 369 |
+
break
|
| 370 |
+
yield _sse(*item)
|
| 371 |
+
finally:
|
| 372 |
+
if not task.done():
|
| 373 |
+
task.cancel() # client went away mid-turn: stop the LLM work
|
| 374 |
+
# surface unexpected runner crashes instead of swallowing them
|
| 375 |
+
with contextlib.suppress(asyncio.CancelledError):
|
| 376 |
+
await task
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
@router.post("/chat/stream")
|
| 380 |
+
async def chat_stream(
|
| 381 |
+
body: ChatRequest,
|
| 382 |
+
request: Request,
|
| 383 |
+
db: AsyncSession = Depends(get_session),
|
| 384 |
+
router_=Depends(get_router),
|
| 385 |
+
settings: Settings = Depends(get_settings),
|
| 386 |
+
) -> StreamingResponse:
|
| 387 |
+
"""Streaming variant of /chat (SSE over POST). Tenant resolution and the
|
| 388 |
+
kill-switch run BEFORE the stream starts so a deleted slug is a plain 404
|
| 389 |
+
and a paused tenant a plain 403 — never a stream, never an LLM call."""
|
| 390 |
+
tenant = await _resolve_tenant(db, request.query_params.get("t"))
|
| 391 |
+
_ensure_chatbot_enabled(tenant)
|
| 392 |
+
return StreamingResponse(
|
| 393 |
+
_stream_events(
|
| 394 |
+
db=db, router_=router_, settings=settings, tenant=tenant,
|
| 395 |
+
message=body.message, session_id=body.session_id,
|
| 396 |
+
attachment_ids=body.attachment_ids,
|
| 397 |
+
),
|
| 398 |
+
media_type="text/event-stream",
|
| 399 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 400 |
)
|
| 401 |
|
| 402 |
|
app/routes/demo.py
CHANGED
|
@@ -15,11 +15,17 @@ limit today, and adding one is out of this feature's scope):
|
|
| 15 |
- at most DEMO_MAX_ACTIVE live demo tenants at any moment.
|
| 16 |
Together these cap the worst-case number of bots that can be burning free-tier
|
| 17 |
LLM calls even if a demo link goes viral.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
import asyncio
|
|
|
|
| 23 |
import ipaddress
|
| 24 |
import logging
|
| 25 |
import secrets
|
|
@@ -174,6 +180,16 @@ def _client_ip(request: Request) -> str:
|
|
| 174 |
return request.client.host if request.client else "unknown"
|
| 175 |
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
# --- progress registry ("teatro de progreso") -----------------------------
|
| 178 |
|
| 179 |
_PROGRESS_MAX = 200
|
|
@@ -336,8 +352,14 @@ async def _unique_demo_slug(db: AsyncSession) -> str:
|
|
| 336 |
async def demo_start(
|
| 337 |
body: DemoStartIn, request: Request, db: AsyncSession = Depends(get_session)
|
| 338 |
) -> dict:
|
|
|
|
|
|
|
|
|
|
| 339 |
# Cheapest gate first: the per-IP limiter also shields the DNS resolver.
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
| 341 |
raise HTTPException(status_code=429, detail=MSG_IP_LIMIT)
|
| 342 |
url = await validate_demo_url(body.url)
|
| 343 |
|
|
@@ -345,15 +367,16 @@ async def demo_start(
|
|
| 345 |
today = now.date().isoformat()
|
| 346 |
daily = await get_config(db, _DAILY_KEY) or {}
|
| 347 |
used_today = int(daily.get("count", 0)) if daily.get("date") == today else 0
|
| 348 |
-
if
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
|
|
|
| 357 |
|
| 358 |
domain = (urlsplit(url).hostname or "").removeprefix("www.")
|
| 359 |
slug = await _unique_demo_slug(db)
|
|
@@ -382,8 +405,14 @@ async def demo_start(
|
|
| 382 |
except Exception: # noqa: BLE001 - lead capture must never break the demo start
|
| 383 |
log.warning("demo handoff capture failed (non-fatal)", exc_info=True)
|
| 384 |
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
await db.commit()
|
|
|
|
|
|
|
| 387 |
|
| 388 |
_progress_start(slug)
|
| 389 |
_spawn(_run_demo_index(slug, src.id))
|
|
|
|
| 15 |
- at most DEMO_MAX_ACTIVE live demo tenants at any moment.
|
| 16 |
Together these cap the worst-case number of bots that can be burning free-tier
|
| 17 |
LLM calls even if a demo link goes viral.
|
| 18 |
+
|
| 19 |
+
Two refinements: only SUCCESSFUL starts consume quota (a rejected URL never
|
| 20 |
+
burns the visitor's hourly budget nor the daily counter), and a request whose
|
| 21 |
+
X-Admin-Token header matches settings.admin_token bypasses all three caps so
|
| 22 |
+
the founder can test from his own IP without eating visitor quota.
|
| 23 |
"""
|
| 24 |
|
| 25 |
from __future__ import annotations
|
| 26 |
|
| 27 |
import asyncio
|
| 28 |
+
import hmac
|
| 29 |
import ipaddress
|
| 30 |
import logging
|
| 31 |
import secrets
|
|
|
|
| 180 |
return request.client.host if request.client else "unknown"
|
| 181 |
|
| 182 |
|
| 183 |
+
def _is_admin_request(request: Request) -> bool:
|
| 184 |
+
"""True when the request carries X-Admin-Token matching the configured
|
| 185 |
+
admin token (constant-time compare, same as deps.require_admin). The
|
| 186 |
+
founder demos from his own IP, so an admin start skips the per-IP, daily
|
| 187 |
+
and active-demo caps — and is not counted against any of them."""
|
| 188 |
+
token = request.headers.get("x-admin-token", "")
|
| 189 |
+
admin_token = get_settings().admin_token
|
| 190 |
+
return bool(token) and bool(admin_token) and hmac.compare_digest(token, admin_token)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
# --- progress registry ("teatro de progreso") -----------------------------
|
| 194 |
|
| 195 |
_PROGRESS_MAX = 200
|
|
|
|
| 352 |
async def demo_start(
|
| 353 |
body: DemoStartIn, request: Request, db: AsyncSession = Depends(get_session)
|
| 354 |
) -> dict:
|
| 355 |
+
# X-Admin-Token: the founder testing from his own IP bypasses every cap.
|
| 356 |
+
is_admin = _is_admin_request(request)
|
| 357 |
+
client_ip = _client_ip(request)
|
| 358 |
# Cheapest gate first: the per-IP limiter also shields the DNS resolver.
|
| 359 |
+
# PEEK only — quota is consumed (record) just after the demo tenant is
|
| 360 |
+
# durably created, so SSRF-rejected/invalid attempts don't burn the
|
| 361 |
+
# visitor's hourly budget.
|
| 362 |
+
if not is_admin and not _ip_limiter.would_allow(client_ip):
|
| 363 |
raise HTTPException(status_code=429, detail=MSG_IP_LIMIT)
|
| 364 |
url = await validate_demo_url(body.url)
|
| 365 |
|
|
|
|
| 367 |
today = now.date().isoformat()
|
| 368 |
daily = await get_config(db, _DAILY_KEY) or {}
|
| 369 |
used_today = int(daily.get("count", 0)) if daily.get("date") == today else 0
|
| 370 |
+
if not is_admin:
|
| 371 |
+
if used_today >= DEMO_DAILY_CAP:
|
| 372 |
+
raise HTTPException(status_code=429, detail=MSG_DAILY_LIMIT)
|
| 373 |
+
active = (
|
| 374 |
+
await db.execute(
|
| 375 |
+
select(func.count()).select_from(Tenant).where(Tenant.is_demo.is_(True))
|
| 376 |
+
)
|
| 377 |
+
).scalar_one()
|
| 378 |
+
if active >= DEMO_MAX_ACTIVE:
|
| 379 |
+
raise HTTPException(status_code=429, detail=MSG_ACTIVE_LIMIT)
|
| 380 |
|
| 381 |
domain = (urlsplit(url).hostname or "").removeprefix("www.")
|
| 382 |
slug = await _unique_demo_slug(db)
|
|
|
|
| 405 |
except Exception: # noqa: BLE001 - lead capture must never break the demo start
|
| 406 |
log.warning("demo handoff capture failed (non-fatal)", exc_info=True)
|
| 407 |
|
| 408 |
+
# Consumption is recorded ONLY for real (non-admin) starts that got this
|
| 409 |
+
# far: failed validations/caps above never reach these lines, so they
|
| 410 |
+
# never burn the visitor's hourly quota nor the global daily counter.
|
| 411 |
+
if not is_admin:
|
| 412 |
+
await upsert_config(db, _DAILY_KEY, {"date": today, "count": used_today + 1})
|
| 413 |
await db.commit()
|
| 414 |
+
if not is_admin:
|
| 415 |
+
_ip_limiter.record(client_ip)
|
| 416 |
|
| 417 |
_progress_start(slug)
|
| 418 |
_spawn(_run_demo_index(slug, src.id))
|
app/schemas.py
CHANGED
|
@@ -31,6 +31,14 @@ ShopDomain = Annotated[str | None, AfterValidator(_normalize_shop)]
|
|
| 31 |
class ChatRequest(BaseModel):
|
| 32 |
message: str = Field(min_length=1, max_length=4000)
|
| 33 |
session_id: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
class ChatResponse(BaseModel):
|
|
|
|
| 31 |
class ChatRequest(BaseModel):
|
| 32 |
message: str = Field(min_length=1, max_length=4000)
|
| 33 |
session_id: str | None = None
|
| 34 |
+
# Ids of files the visitor uploaded via /chat/upload for THIS conversation.
|
| 35 |
+
# Server-side they are matched strictly by (id, tenant, session); anything
|
| 36 |
+
# else is silently dropped, so a foreign id can never leak content. Real
|
| 37 |
+
# ids are 32-hex (column is 40): the per-item cap keeps the only otherwise
|
| 38 |
+
# unbounded client string on this endpoint out of the SQL IN clause.
|
| 39 |
+
attachment_ids: list[Annotated[str, Field(max_length=64)]] = Field(
|
| 40 |
+
default_factory=list, max_length=10
|
| 41 |
+
)
|
| 42 |
|
| 43 |
|
| 44 |
class ChatResponse(BaseModel):
|
app/static/widget.js
CHANGED
|
@@ -32,6 +32,18 @@
|
|
| 32 |
hfThanks: "\u00a1Gracias! Un compa\u00f1ero del equipo te responder\u00e1 en breve por email.",
|
| 33 |
hfError: "No he podido enviar el formulario. Int\u00e9ntalo de nuevo en un momento.",
|
| 34 |
open: "Abrir chat de soporte", close: "Cerrar", send: "Enviar",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
starters: ["\u00bfQu\u00e9 ofrec\u00e9is?", "Quiero m\u00e1s informaci\u00f3n", "Hablar con una persona"]
|
| 36 |
} : {
|
| 37 |
status: "Online \u00b7 Replies in seconds",
|
|
@@ -46,6 +58,18 @@
|
|
| 46 |
hfThanks: "Thank you! Someone from the team will reply to you by email shortly.",
|
| 47 |
hfError: "The form could not be sent. Please try again in a moment.",
|
| 48 |
open: "Open support chat", close: "Close", send: "Send",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
starters: ["What do you offer?", "I want more information", "Talk to a person"]
|
| 50 |
};
|
| 51 |
|
|
@@ -74,6 +98,13 @@
|
|
| 74 |
var ICON_SEND = '<svg viewBox="0 0 24 24"><path d="M3 11l18-8-8 18-2.5-7.5L3 11z"/></svg>';
|
| 75 |
var ICON_WA = '<svg viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>';
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
function fillAvatar(el) {
|
| 78 |
if (cfg.avatar_url) {
|
| 79 |
el.style.background = shade(cfg.brand_color, 40) + " url('" + cfg.avatar_url + "') center/cover no-repeat";
|
|
@@ -136,6 +167,10 @@
|
|
| 136 |
"#ssb-bubble{position:fixed;bottom:92px;right:20px;max-width:230px;background:#fff;color:#1f2937;border-radius:14px;padding:12px 14px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:14px;line-height:1.35;box-shadow:0 10px 30px rgba(0,0,0,.22);cursor:pointer;z-index:99998;animation:ssbpop .3s ease}" +
|
| 137 |
"@keyframes ssbpop{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}" +
|
| 138 |
"#ssb-head .ssb-x{margin-left:auto;background:transparent;border:0;color:#fff;font-size:24px;cursor:pointer;opacity:.85;line-height:1}" +
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
"#ssb-msgs{flex:1;overflow-y:auto;padding:16px 14px;background:#f4f5f7;display:flex;flex-direction:column;gap:10px}" +
|
| 140 |
".ssb-row{display:flex;align-items:flex-end;gap:8px;max-width:88%}" +
|
| 141 |
".ssb-row.user{align-self:flex-end;flex-direction:row-reverse}.ssb-row.bot{align-self:flex-start}" +
|
|
@@ -147,8 +182,18 @@
|
|
| 147 |
".ssb-b ul{margin:4px 0;padding-left:18px}.ssb-b li{margin:2px 0}.ssb-b code{background:#eee;padding:1px 4px;border-radius:4px;font-size:13px}" +
|
| 148 |
".ssb-typing{display:flex;gap:4px;padding:12px 14px}.ssb-typing span{width:7px;height:7px;border-radius:50%;background:#bbb;animation:ssbbounce 1.2s infinite}" +
|
| 149 |
".ssb-typing span:nth-child(2){animation-delay:.2s}.ssb-typing span:nth-child(3){animation-delay:.4s}" +
|
|
|
|
| 150 |
"@keyframes ssbbounce{0%,60%,100%{transform:translateY(0);opacity:.5}30%{transform:translateY(-5px);opacity:1}}" +
|
| 151 |
"#ssb-form{display:flex;align-items:center;gap:8px;padding:10px;border-top:1px solid #ececef;background:#fff}" +
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
"#ssb-input{flex:1;border:1px solid #e0e0e4;border-radius:22px;padding:11px 15px;font-size:14px;outline:none}" +
|
| 153 |
"#ssb-input:focus{border-color:" + c + "}" +
|
| 154 |
"#ssb-send{width:42px;height:42px;border-radius:50%;border:0;background:" + c + ";color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex:none}#ssb-send svg{width:19px;height:19px;fill:#fff}" +
|
|
@@ -208,7 +253,7 @@
|
|
| 208 |
|
| 209 |
var launch = document.createElement("button");
|
| 210 |
launch.id = "ssb-launch"; launch.setAttribute("aria-label", T.open);
|
| 211 |
-
launch.innerHTML = '<span class="ssb-pulse"></span>' + ICON_AGENT + '<span class="ssb-dot"></span>';
|
| 212 |
|
| 213 |
var panel = document.createElement("div");
|
| 214 |
panel.id = "ssb-panel";
|
|
@@ -216,9 +261,14 @@
|
|
| 216 |
'<div id="ssb-head"><div class="ssb-av"></div><div>' +
|
| 217 |
'<div class="ssb-name">' + esc(name) + '</div>' +
|
| 218 |
'<div class="ssb-status"><i></i>' + T.status + '</div></div>' +
|
|
|
|
| 219 |
'<button class="ssb-x" aria-label="' + T.close + '">×</button></div>' +
|
| 220 |
'<div id="ssb-msgs"></div>' +
|
| 221 |
-
'<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
'<button id="ssb-send" type="submit" aria-label="' + T.send + '">' + ICON_SEND + '</button></form>' +
|
| 223 |
'<div id="ssb-foot">' + T.footer + '</div>';
|
| 224 |
|
|
@@ -231,6 +281,201 @@
|
|
| 231 |
var input = panel.querySelector("#ssb-input");
|
| 232 |
var greeted = false;
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
function row(who) {
|
| 235 |
var r = document.createElement("div"); r.className = "ssb-row " + who;
|
| 236 |
if (who === "bot") { var a = document.createElement("div"); a.className = "ssb-av"; fillAvatar(a); r.appendChild(a); }
|
|
@@ -255,7 +500,21 @@
|
|
| 255 |
});
|
| 256 |
msgs.appendChild(wrap); msgs.scrollTop = msgs.scrollHeight;
|
| 257 |
}
|
| 258 |
-
function userMsg(t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
function typing() { var b = row("bot"); b.classList.add("ssb-typing"); b.innerHTML = "<span></span><span></span><span></span>"; msgs.scrollTop = msgs.scrollHeight; return b; }
|
| 260 |
|
| 261 |
// Suggested starter chips — per tenant (a services store needs different
|
|
@@ -278,6 +537,7 @@
|
|
| 278 |
var bubble = document.getElementById("ssb-bubble"); if (bubble) bubble.remove();
|
| 279 |
panel.classList.toggle("open");
|
| 280 |
if (panel.classList.contains("open")) {
|
|
|
|
| 281 |
if (!greeted) { botMsg(cfg.welcome); renderStarters(); greeted = true; }
|
| 282 |
input.focus();
|
| 283 |
}
|
|
@@ -285,22 +545,162 @@
|
|
| 285 |
launch.addEventListener("click", toggle);
|
| 286 |
panel.querySelector(".ssb-x").addEventListener("click", toggle);
|
| 287 |
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
renderCards(d.products);
|
| 298 |
if (d.handoff) renderHandoffForm();
|
| 299 |
msgs.scrollTop = msgs.scrollHeight;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
}).catch(function () {
|
| 301 |
-
t.classList.remove("ssb-typing"); t.
|
|
|
|
|
|
|
| 302 |
});
|
| 303 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
form.addEventListener("submit", function (e) { e.preventDefault(); sendText(input.value); });
|
| 305 |
|
| 306 |
// Proactive nudge: after a bit, invite the visitor (once) if chat unopened.
|
|
@@ -310,6 +710,7 @@
|
|
| 310 |
b.textContent = T.bubble;
|
| 311 |
b.addEventListener("click", toggle);
|
| 312 |
document.body.appendChild(b);
|
|
|
|
| 313 |
setTimeout(function () { var x = document.getElementById("ssb-bubble"); if (x) x.remove(); }, 18000);
|
| 314 |
}, 22000);
|
| 315 |
|
|
@@ -338,8 +739,10 @@
|
|
| 338 |
}).then(function (r) { return r.json(); }).then(function () {
|
| 339 |
box.remove();
|
| 340 |
botMsg(T.hfThanks);
|
|
|
|
| 341 |
}).catch(function () {
|
| 342 |
botMsg(T.hfError);
|
|
|
|
| 343 |
});
|
| 344 |
});
|
| 345 |
}
|
|
|
|
| 32 |
hfThanks: "\u00a1Gracias! Un compa\u00f1ero del equipo te responder\u00e1 en breve por email.",
|
| 33 |
hfError: "No he podido enviar el formulario. Int\u00e9ntalo de nuevo en un momento.",
|
| 34 |
open: "Abrir chat de soporte", close: "Cerrar", send: "Enviar",
|
| 35 |
+
mute: "Silenciar avisos", unmute: "Activar avisos",
|
| 36 |
+
newMsg: "Nuevo mensaje",
|
| 37 |
+
stThinking: "Pensando\u2026",
|
| 38 |
+
stCatalog: "Buscando en el cat\u00e1logo\u2026",
|
| 39 |
+
stOrder: "Consultando el pedido\u2026",
|
| 40 |
+
stDocs: "Leyendo la documentaci\u00f3n\u2026",
|
| 41 |
+
stWait: "Un momento\u2026",
|
| 42 |
+
attach: "Adjuntar archivo",
|
| 43 |
+
uploading: "Subiendo\u2026",
|
| 44 |
+
tooBig: "Archivo demasiado grande (m\u00e1x. 5 MB)",
|
| 45 |
+
upFail: "No se pudo subir el archivo",
|
| 46 |
+
attached: "Adjunto",
|
| 47 |
starters: ["\u00bfQu\u00e9 ofrec\u00e9is?", "Quiero m\u00e1s informaci\u00f3n", "Hablar con una persona"]
|
| 48 |
} : {
|
| 49 |
status: "Online \u00b7 Replies in seconds",
|
|
|
|
| 58 |
hfThanks: "Thank you! Someone from the team will reply to you by email shortly.",
|
| 59 |
hfError: "The form could not be sent. Please try again in a moment.",
|
| 60 |
open: "Open support chat", close: "Close", send: "Send",
|
| 61 |
+
mute: "Mute notifications", unmute: "Unmute notifications",
|
| 62 |
+
newMsg: "New message",
|
| 63 |
+
stThinking: "Thinking\u2026",
|
| 64 |
+
stCatalog: "Searching the catalog\u2026",
|
| 65 |
+
stOrder: "Checking your order\u2026",
|
| 66 |
+
stDocs: "Reading the documentation\u2026",
|
| 67 |
+
stWait: "One moment\u2026",
|
| 68 |
+
attach: "Attach a file",
|
| 69 |
+
uploading: "Uploading\u2026",
|
| 70 |
+
tooBig: "File too large (max 5 MB)",
|
| 71 |
+
upFail: "The file could not be uploaded",
|
| 72 |
+
attached: "Attachment",
|
| 73 |
starters: ["What do you offer?", "I want more information", "Talk to a person"]
|
| 74 |
};
|
| 75 |
|
|
|
|
| 98 |
var ICON_SEND = '<svg viewBox="0 0 24 24"><path d="M3 11l18-8-8 18-2.5-7.5L3 11z"/></svg>';
|
| 99 |
var ICON_WA = '<svg viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg>';
|
| 100 |
|
| 101 |
+
// Speaker on / speaker muted (Material-style paths) for the sound toggle.
|
| 102 |
+
var ICON_SND_ON = '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>';
|
| 103 |
+
var ICON_SND_OFF = '<svg viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>';
|
| 104 |
+
|
| 105 |
+
// Paperclip (attach a PDF/photo to the conversation).
|
| 106 |
+
var ICON_CLIP = '<svg viewBox="0 0 24 24"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>';
|
| 107 |
+
|
| 108 |
function fillAvatar(el) {
|
| 109 |
if (cfg.avatar_url) {
|
| 110 |
el.style.background = shade(cfg.brand_color, 40) + " url('" + cfg.avatar_url + "') center/cover no-repeat";
|
|
|
|
| 167 |
"#ssb-bubble{position:fixed;bottom:92px;right:20px;max-width:230px;background:#fff;color:#1f2937;border-radius:14px;padding:12px 14px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:14px;line-height:1.35;box-shadow:0 10px 30px rgba(0,0,0,.22);cursor:pointer;z-index:99998;animation:ssbpop .3s ease}" +
|
| 168 |
"@keyframes ssbpop{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}" +
|
| 169 |
"#ssb-head .ssb-x{margin-left:auto;background:transparent;border:0;color:#fff;font-size:24px;cursor:pointer;opacity:.85;line-height:1}" +
|
| 170 |
+
"#ssb-mute{margin-left:auto;width:32px;height:32px;background:transparent;border:0;cursor:pointer;opacity:.85;display:flex;align-items:center;justify-content:center;padding:0;flex:none}" +
|
| 171 |
+
"#ssb-mute:hover{opacity:1}#ssb-mute svg{width:20px;height:20px;fill:#fff}" +
|
| 172 |
+
"#ssb-head .ssb-x{margin-left:6px}" +
|
| 173 |
+
"#ssb-launch .ssb-badge{position:absolute;top:-4px;right:-4px;min-width:20px;height:20px;box-sizing:border-box;padding:0 5px;border-radius:10px;background:#e11d48;border:2px solid #fff;color:#fff;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:11px;font-weight:700;line-height:16px;text-align:center;display:none}" +
|
| 174 |
"#ssb-msgs{flex:1;overflow-y:auto;padding:16px 14px;background:#f4f5f7;display:flex;flex-direction:column;gap:10px}" +
|
| 175 |
".ssb-row{display:flex;align-items:flex-end;gap:8px;max-width:88%}" +
|
| 176 |
".ssb-row.user{align-self:flex-end;flex-direction:row-reverse}.ssb-row.bot{align-self:flex-start}" +
|
|
|
|
| 182 |
".ssb-b ul{margin:4px 0;padding-left:18px}.ssb-b li{margin:2px 0}.ssb-b code{background:#eee;padding:1px 4px;border-radius:4px;font-size:13px}" +
|
| 183 |
".ssb-typing{display:flex;gap:4px;padding:12px 14px}.ssb-typing span{width:7px;height:7px;border-radius:50%;background:#bbb;animation:ssbbounce 1.2s infinite}" +
|
| 184 |
".ssb-typing span:nth-child(2){animation-delay:.2s}.ssb-typing span:nth-child(3){animation-delay:.4s}" +
|
| 185 |
+
".ssb-b.ssb-stage{color:#8b8b94;font-style:italic}" +
|
| 186 |
"@keyframes ssbbounce{0%,60%,100%{transform:translateY(0);opacity:.5}30%{transform:translateY(-5px);opacity:1}}" +
|
| 187 |
"#ssb-form{display:flex;align-items:center;gap:8px;padding:10px;border-top:1px solid #ececef;background:#fff}" +
|
| 188 |
+
"#ssb-att{display:none;flex-wrap:wrap;gap:6px;padding:8px 12px;border-top:1px solid #ececef;background:#fff}" +
|
| 189 |
+
".ssb-att-chip{display:inline-flex;align-items:center;gap:6px;max-width:100%;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:14px;padding:4px 10px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:12px;color:#334155}" +
|
| 190 |
+
".ssb-att-chip .ssb-att-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px}" +
|
| 191 |
+
".ssb-att-chip button{border:0;background:transparent;cursor:pointer;color:#64748b;font-size:15px;line-height:1;padding:0;flex:none}" +
|
| 192 |
+
".ssb-att-line{font-size:12px;font-style:italic;opacity:.85}" +
|
| 193 |
+
"#ssb-attach{width:38px;height:38px;border-radius:50%;border:0;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;flex:none;padding:0}" +
|
| 194 |
+
"#ssb-attach svg{width:20px;height:20px;fill:#9aa0a6}#ssb-attach:hover svg{fill:" + c + "}" +
|
| 195 |
+
"#ssb-file{display:none}" +
|
| 196 |
+
".ssb-toast{position:absolute;left:12px;right:12px;bottom:78px;background:#1f2937;color:#fff;border-radius:10px;padding:10px 12px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:13px;text-align:center;box-shadow:0 6px 18px rgba(0,0,0,.25);z-index:2;animation:ssbpop .25s ease}" +
|
| 197 |
"#ssb-input{flex:1;border:1px solid #e0e0e4;border-radius:22px;padding:11px 15px;font-size:14px;outline:none}" +
|
| 198 |
"#ssb-input:focus{border-color:" + c + "}" +
|
| 199 |
"#ssb-send{width:42px;height:42px;border-radius:50%;border:0;background:" + c + ";color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex:none}#ssb-send svg{width:19px;height:19px;fill:#fff}" +
|
|
|
|
| 253 |
|
| 254 |
var launch = document.createElement("button");
|
| 255 |
launch.id = "ssb-launch"; launch.setAttribute("aria-label", T.open);
|
| 256 |
+
launch.innerHTML = '<span class="ssb-pulse"></span>' + ICON_AGENT + '<span class="ssb-dot"></span><span class="ssb-badge"></span>';
|
| 257 |
|
| 258 |
var panel = document.createElement("div");
|
| 259 |
panel.id = "ssb-panel";
|
|
|
|
| 261 |
'<div id="ssb-head"><div class="ssb-av"></div><div>' +
|
| 262 |
'<div class="ssb-name">' + esc(name) + '</div>' +
|
| 263 |
'<div class="ssb-status"><i></i>' + T.status + '</div></div>' +
|
| 264 |
+
'<button id="ssb-mute" type="button"></button>' +
|
| 265 |
'<button class="ssb-x" aria-label="' + T.close + '">×</button></div>' +
|
| 266 |
'<div id="ssb-msgs"></div>' +
|
| 267 |
+
'<div id="ssb-att"></div>' +
|
| 268 |
+
'<form id="ssb-form">' +
|
| 269 |
+
'<button id="ssb-attach" type="button" aria-label="' + T.attach + '" title="' + T.attach + '">' + ICON_CLIP + '</button>' +
|
| 270 |
+
'<input id="ssb-file" type="file" accept="image/*,application/pdf" />' +
|
| 271 |
+
'<input id="ssb-input" type="text" autocomplete="off" placeholder="' + T.input + '" />' +
|
| 272 |
'<button id="ssb-send" type="submit" aria-label="' + T.send + '">' + ICON_SEND + '</button></form>' +
|
| 273 |
'<div id="ssb-foot">' + T.footer + '</div>';
|
| 274 |
|
|
|
|
| 281 |
var input = panel.querySelector("#ssb-input");
|
| 282 |
var greeted = false;
|
| 283 |
|
| 284 |
+
// ---- Visitor uploads (Phase 3): paperclip -> chip -> ids on next send ----
|
| 285 |
+
var attBox = panel.querySelector("#ssb-att");
|
| 286 |
+
var attachBtn = panel.querySelector("#ssb-attach");
|
| 287 |
+
var fileInput = panel.querySelector("#ssb-file");
|
| 288 |
+
var attachments = []; // [{id, name}] uploaded + ready for the next message
|
| 289 |
+
|
| 290 |
+
function toast(msg) {
|
| 291 |
+
try {
|
| 292 |
+
var old = panel.querySelector(".ssb-toast"); if (old) old.remove();
|
| 293 |
+
var el = document.createElement("div"); el.className = "ssb-toast"; el.textContent = msg;
|
| 294 |
+
panel.appendChild(el);
|
| 295 |
+
setTimeout(function () { try { el.remove(); } catch (e) {} }, 3500);
|
| 296 |
+
} catch (e) {}
|
| 297 |
+
}
|
| 298 |
+
function paintAtt() {
|
| 299 |
+
try { attBox.style.display = attBox.children.length ? "flex" : "none"; } catch (e) {}
|
| 300 |
+
}
|
| 301 |
+
function addChip(label) {
|
| 302 |
+
var ch = document.createElement("span"); ch.className = "ssb-att-chip";
|
| 303 |
+
var nm = document.createElement("span"); nm.className = "ssb-att-name"; nm.textContent = label;
|
| 304 |
+
ch.appendChild(nm);
|
| 305 |
+
attBox.appendChild(ch); paintAtt();
|
| 306 |
+
return ch;
|
| 307 |
+
}
|
| 308 |
+
function chipReady(ch, id, name) {
|
| 309 |
+
ch.querySelector(".ssb-att-name").textContent = name;
|
| 310 |
+
var x = document.createElement("button"); x.type = "button";
|
| 311 |
+
x.textContent = "×"; x.setAttribute("aria-label", T.close);
|
| 312 |
+
x.addEventListener("click", function () {
|
| 313 |
+
for (var i = attachments.length - 1; i >= 0; i--) {
|
| 314 |
+
if (attachments[i].id === id) attachments.splice(i, 1);
|
| 315 |
+
}
|
| 316 |
+
try { ch.remove(); } catch (e) {}
|
| 317 |
+
paintAtt();
|
| 318 |
+
});
|
| 319 |
+
ch.appendChild(x);
|
| 320 |
+
attachments.push({ id: id, name: name });
|
| 321 |
+
}
|
| 322 |
+
function takeAttachments() {
|
| 323 |
+
var ids = [], names = [];
|
| 324 |
+
for (var i = 0; i < attachments.length; i++) { ids.push(attachments[i].id); names.push(attachments[i].name); }
|
| 325 |
+
attachments = [];
|
| 326 |
+
try { while (attBox.firstChild) attBox.removeChild(attBox.firstChild); } catch (e) {}
|
| 327 |
+
paintAtt();
|
| 328 |
+
return { ids: ids, names: names };
|
| 329 |
+
}
|
| 330 |
+
function uploadFile(f) {
|
| 331 |
+
if (!f || !window.FormData) return;
|
| 332 |
+
if (attachments.length >= 3) { toast(T.upFail); return; }
|
| 333 |
+
if (f.size > 5 * 1024 * 1024) { toast(T.tooBig); return; }
|
| 334 |
+
var chip = addChip(T.uploading);
|
| 335 |
+
var fd = new FormData();
|
| 336 |
+
fd.append("file", f, f.name || "archivo");
|
| 337 |
+
var sid = getSid(); if (sid) fd.append("session_id", sid);
|
| 338 |
+
fetch(BACKEND + "/chat/upload" + QS, { method: "POST", body: fd })
|
| 339 |
+
.then(function (r) { if (!r.ok) throw new Error("up"); return r.json(); })
|
| 340 |
+
.then(function (d) {
|
| 341 |
+
if (!d || !d.attachment_id) throw new Error("up");
|
| 342 |
+
// Adopt the server's session id: the next message must land on the
|
| 343 |
+
// SAME session the attachment is bound to.
|
| 344 |
+
setSid(d.session_id);
|
| 345 |
+
chipReady(chip, d.attachment_id, d.filename || f.name || "archivo");
|
| 346 |
+
})
|
| 347 |
+
.catch(function () {
|
| 348 |
+
try { chip.remove(); } catch (e) {}
|
| 349 |
+
paintAtt();
|
| 350 |
+
toast(T.upFail);
|
| 351 |
+
});
|
| 352 |
+
}
|
| 353 |
+
if (attachBtn) attachBtn.addEventListener("click", function () {
|
| 354 |
+
try { fileInput.click(); } catch (e) {}
|
| 355 |
+
});
|
| 356 |
+
if (fileInput) fileInput.addEventListener("change", function () {
|
| 357 |
+
try {
|
| 358 |
+
var f = fileInput.files && fileInput.files[0];
|
| 359 |
+
fileInput.value = "";
|
| 360 |
+
uploadFile(f);
|
| 361 |
+
} catch (e) {}
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
// ---- Notifications: subtle pop sound + unread badge + tab title flash ----
|
| 365 |
+
var soundOn = true;
|
| 366 |
+
try { soundOn = localStorage.getItem("ssb_sound") !== "off"; } catch (e) {}
|
| 367 |
+
|
| 368 |
+
// Autoplay policy: only create/resume the AudioContext after the first
|
| 369 |
+
// real user gesture on the page; the one-time listeners remove themselves.
|
| 370 |
+
var audioCtx = null;
|
| 371 |
+
var GESTURE_OPTS = { passive: true };
|
| 372 |
+
function armAudio() {
|
| 373 |
+
try {
|
| 374 |
+
document.removeEventListener("pointerdown", armAudio, GESTURE_OPTS);
|
| 375 |
+
document.removeEventListener("keydown", armAudio, GESTURE_OPTS);
|
| 376 |
+
var AC = window.AudioContext || window.webkitAudioContext;
|
| 377 |
+
if (!AC) return;
|
| 378 |
+
if (!audioCtx) audioCtx = new AC();
|
| 379 |
+
if (audioCtx.state === "suspended") {
|
| 380 |
+
var p = audioCtx.resume();
|
| 381 |
+
if (p && p.catch) p.catch(function () {});
|
| 382 |
+
}
|
| 383 |
+
} catch (e) {}
|
| 384 |
+
}
|
| 385 |
+
try {
|
| 386 |
+
document.addEventListener("pointerdown", armAudio, GESTURE_OPTS);
|
| 387 |
+
document.addEventListener("keydown", armAudio, GESTURE_OPTS);
|
| 388 |
+
} catch (e) {}
|
| 389 |
+
|
| 390 |
+
// Short quiet "pop": sine sweep with a fast gain envelope (~120ms).
|
| 391 |
+
function playPop() {
|
| 392 |
+
try {
|
| 393 |
+
if (!soundOn || !audioCtx || audioCtx.state !== "running") return;
|
| 394 |
+
var t0 = audioCtx.currentTime;
|
| 395 |
+
var osc = audioCtx.createOscillator();
|
| 396 |
+
var gain = audioCtx.createGain();
|
| 397 |
+
osc.type = "sine";
|
| 398 |
+
osc.frequency.setValueAtTime(740, t0);
|
| 399 |
+
osc.frequency.exponentialRampToValueAtTime(420, t0 + 0.11);
|
| 400 |
+
gain.gain.setValueAtTime(0.0001, t0);
|
| 401 |
+
gain.gain.exponentialRampToValueAtTime(0.07, t0 + 0.02);
|
| 402 |
+
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.12);
|
| 403 |
+
osc.connect(gain); gain.connect(audioCtx.destination);
|
| 404 |
+
osc.start(t0); osc.stop(t0 + 0.13);
|
| 405 |
+
} catch (e) {}
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
var muteBtn = panel.querySelector("#ssb-mute");
|
| 409 |
+
function paintMute() {
|
| 410 |
+
try {
|
| 411 |
+
if (!muteBtn) return;
|
| 412 |
+
muteBtn.innerHTML = soundOn ? ICON_SND_ON : ICON_SND_OFF;
|
| 413 |
+
muteBtn.setAttribute("aria-label", soundOn ? T.mute : T.unmute);
|
| 414 |
+
} catch (e) {}
|
| 415 |
+
}
|
| 416 |
+
if (muteBtn) muteBtn.addEventListener("click", function () {
|
| 417 |
+
soundOn = !soundOn;
|
| 418 |
+
try { localStorage.setItem("ssb_sound", soundOn ? "on" : "off"); } catch (e) {}
|
| 419 |
+
paintMute();
|
| 420 |
+
});
|
| 421 |
+
paintMute();
|
| 422 |
+
|
| 423 |
+
// Unread badge on the launcher: bot messages that arrive while closed.
|
| 424 |
+
var unread = 0;
|
| 425 |
+
var badgeEl = launch.querySelector(".ssb-badge");
|
| 426 |
+
function setBadge(n) {
|
| 427 |
+
unread = n;
|
| 428 |
+
try {
|
| 429 |
+
if (!badgeEl) return;
|
| 430 |
+
if (n > 0) { badgeEl.textContent = n > 9 ? "9+" : String(n); badgeEl.style.display = "block"; }
|
| 431 |
+
else badgeEl.style.display = "none";
|
| 432 |
+
} catch (e) {}
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
// Tab title flash while the document is hidden. The original title is
|
| 436 |
+
// re-read each time flashing starts (so merchant SPA title changes are
|
| 437 |
+
// respected) and restored EXACTLY when flashing stops.
|
| 438 |
+
var hiddenN = 0, flashTimer = null, flashOrig = null;
|
| 439 |
+
function startFlash() {
|
| 440 |
+
try {
|
| 441 |
+
if (flashTimer) return;
|
| 442 |
+
flashOrig = document.title;
|
| 443 |
+
var shown = false;
|
| 444 |
+
var swap = function () {
|
| 445 |
+
try {
|
| 446 |
+
shown = !shown;
|
| 447 |
+
document.title = shown ? "(" + hiddenN + ") " + T.newMsg : flashOrig;
|
| 448 |
+
} catch (e) {}
|
| 449 |
+
};
|
| 450 |
+
swap();
|
| 451 |
+
flashTimer = setInterval(swap, 1500);
|
| 452 |
+
} catch (e) {}
|
| 453 |
+
}
|
| 454 |
+
function stopFlash() {
|
| 455 |
+
try {
|
| 456 |
+
hiddenN = 0;
|
| 457 |
+
if (!flashTimer) return;
|
| 458 |
+
clearInterval(flashTimer); flashTimer = null;
|
| 459 |
+
if (flashOrig !== null) document.title = flashOrig;
|
| 460 |
+
flashOrig = null;
|
| 461 |
+
} catch (e) {}
|
| 462 |
+
}
|
| 463 |
+
try {
|
| 464 |
+
document.addEventListener("visibilitychange", function () { if (!document.hidden) stopFlash(); });
|
| 465 |
+
window.addEventListener("focus", stopFlash);
|
| 466 |
+
} catch (e) {}
|
| 467 |
+
|
| 468 |
+
// A bot message landed: badge / title / sound only when not being watched.
|
| 469 |
+
function notify() {
|
| 470 |
+
try {
|
| 471 |
+
var closed = !panel.classList.contains("open");
|
| 472 |
+
var hidden = !!document.hidden;
|
| 473 |
+
if (closed) setBadge(unread + 1);
|
| 474 |
+
if (hidden) { hiddenN++; startFlash(); }
|
| 475 |
+
if (closed || hidden) playPop();
|
| 476 |
+
} catch (e) {}
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
function row(who) {
|
| 480 |
var r = document.createElement("div"); r.className = "ssb-row " + who;
|
| 481 |
if (who === "bot") { var a = document.createElement("div"); a.className = "ssb-av"; fillAvatar(a); r.appendChild(a); }
|
|
|
|
| 500 |
});
|
| 501 |
msgs.appendChild(wrap); msgs.scrollTop = msgs.scrollHeight;
|
| 502 |
}
|
| 503 |
+
function userMsg(t, attNames) {
|
| 504 |
+
var b = row("user");
|
| 505 |
+
if (attNames && attNames.length) {
|
| 506 |
+
for (var i = 0; i < attNames.length; i++) {
|
| 507 |
+
var ln = document.createElement("div"); ln.className = "ssb-att-line";
|
| 508 |
+
ln.textContent = "[" + T.attached + ": " + attNames[i] + "]";
|
| 509 |
+
b.appendChild(ln);
|
| 510 |
+
}
|
| 511 |
+
var tx = document.createElement("div"); tx.textContent = t;
|
| 512 |
+
b.appendChild(tx);
|
| 513 |
+
} else {
|
| 514 |
+
b.textContent = t;
|
| 515 |
+
}
|
| 516 |
+
msgs.scrollTop = msgs.scrollHeight;
|
| 517 |
+
}
|
| 518 |
function typing() { var b = row("bot"); b.classList.add("ssb-typing"); b.innerHTML = "<span></span><span></span><span></span>"; msgs.scrollTop = msgs.scrollHeight; return b; }
|
| 519 |
|
| 520 |
// Suggested starter chips — per tenant (a services store needs different
|
|
|
|
| 537 |
var bubble = document.getElementById("ssb-bubble"); if (bubble) bubble.remove();
|
| 538 |
panel.classList.toggle("open");
|
| 539 |
if (panel.classList.contains("open")) {
|
| 540 |
+
setBadge(0); stopFlash();
|
| 541 |
if (!greeted) { botMsg(cfg.welcome); renderStarters(); greeted = true; }
|
| 542 |
input.focus();
|
| 543 |
}
|
|
|
|
| 545 |
launch.addEventListener("click", toggle);
|
| 546 |
panel.querySelector(".ssb-x").addEventListener("click", toggle);
|
| 547 |
|
| 548 |
+
// ---- Perceived speed: live stage line while the bot works (Phase 2) ----
|
| 549 |
+
function stageLabel(key) {
|
| 550 |
+
if (key === "thinking") return T.stThinking;
|
| 551 |
+
if (key.indexOf("tool:") === 0) {
|
| 552 |
+
var tool = key.slice(5);
|
| 553 |
+
if (tool === "search_products") return T.stCatalog;
|
| 554 |
+
if (tool === "search_knowledge") return T.stDocs;
|
| 555 |
+
if (tool.indexOf("order") !== -1 || tool === "change_shipping_address" || tool === "start_return") return T.stOrder;
|
| 556 |
+
return T.stWait;
|
| 557 |
+
}
|
| 558 |
+
return T.stWait;
|
| 559 |
+
}
|
| 560 |
+
function showStage(t, key) {
|
| 561 |
+
try {
|
| 562 |
+
t.classList.remove("ssb-typing");
|
| 563 |
+
t.classList.add("ssb-stage");
|
| 564 |
+
t.textContent = stageLabel(key);
|
| 565 |
+
msgs.scrollTop = msgs.scrollHeight;
|
| 566 |
+
} catch (e) {}
|
| 567 |
+
}
|
| 568 |
+
function showTypingDots(t) {
|
| 569 |
+
t.classList.remove("ssb-stage");
|
| 570 |
+
t.classList.add("ssb-typing");
|
| 571 |
+
t.innerHTML = "<span></span><span></span><span></span>";
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
// Typewriter render (~4 chars per animation frame). Instant-finish on a
|
| 575 |
+
// user click/scroll gesture or prefers-reduced-motion; always ends with
|
| 576 |
+
// the full markdown-rendered reply (the typewriter is presentation only).
|
| 577 |
+
function typewrite(el, text, done) {
|
| 578 |
+
var finished = false, i = 0;
|
| 579 |
+
function finish() {
|
| 580 |
+
if (finished) return; finished = true;
|
| 581 |
+
try {
|
| 582 |
+
document.removeEventListener("pointerdown", finish, GESTURE_OPTS);
|
| 583 |
+
msgs.removeEventListener("wheel", finish, GESTURE_OPTS);
|
| 584 |
+
msgs.removeEventListener("touchmove", finish, GESTURE_OPTS);
|
| 585 |
+
} catch (e) {}
|
| 586 |
+
try { el.innerHTML = md(text); } catch (e) { el.textContent = text; }
|
| 587 |
+
msgs.scrollTop = msgs.scrollHeight;
|
| 588 |
+
if (done) done();
|
| 589 |
+
}
|
| 590 |
+
var reduce = false;
|
| 591 |
+
try { reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) {}
|
| 592 |
+
if (reduce || !window.requestAnimationFrame) { finish(); return; }
|
| 593 |
+
try {
|
| 594 |
+
document.addEventListener("pointerdown", finish, GESTURE_OPTS);
|
| 595 |
+
msgs.addEventListener("wheel", finish, GESTURE_OPTS);
|
| 596 |
+
msgs.addEventListener("touchmove", finish, GESTURE_OPTS);
|
| 597 |
+
} catch (e) {}
|
| 598 |
+
function step() {
|
| 599 |
+
if (finished) return;
|
| 600 |
+
i += 4;
|
| 601 |
+
if (i >= text.length) { finish(); return; }
|
| 602 |
+
el.textContent = text.slice(0, i);
|
| 603 |
+
msgs.scrollTop = msgs.scrollHeight;
|
| 604 |
+
window.requestAnimationFrame(step);
|
| 605 |
+
}
|
| 606 |
+
window.requestAnimationFrame(step);
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
// Single arrival point for a bot reply (streamed or classic): Phase 1
|
| 610 |
+
// notify() hook, typewriter text, then cards/handoff exactly as before.
|
| 611 |
+
function deliverReply(t, d) {
|
| 612 |
+
setSid(d.session_id);
|
| 613 |
+
t.classList.remove("ssb-typing"); t.classList.remove("ssb-stage");
|
| 614 |
+
notify();
|
| 615 |
+
typewrite(t, d.reply || "…", function () {
|
| 616 |
renderCards(d.products);
|
| 617 |
if (d.handoff) renderHandoffForm();
|
| 618 |
msgs.scrollTop = msgs.scrollHeight;
|
| 619 |
+
});
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
// Classic non-streaming path: the source of truth for correctness, and
|
| 623 |
+
// the transparent fallback whenever streaming is unavailable or breaks.
|
| 624 |
+
function sendClassic(text, t, attIds) {
|
| 625 |
+
fetch(BACKEND + "/chat" + QS, {
|
| 626 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 627 |
+
body: JSON.stringify({ message: text, session_id: getSid(), attachment_ids: attIds || [] })
|
| 628 |
+
}).then(function (r) { return r.json(); }).then(function (d) {
|
| 629 |
+
deliverReply(t, d);
|
| 630 |
}).catch(function () {
|
| 631 |
+
t.classList.remove("ssb-typing"); t.classList.remove("ssb-stage");
|
| 632 |
+
t.textContent = T.error;
|
| 633 |
+
notify();
|
| 634 |
});
|
| 635 |
}
|
| 636 |
+
|
| 637 |
+
// SSE-over-POST: consume /chat/stream with fetch + ReadableStream (not
|
| 638 |
+
// EventSource). Any problem before the "final" event retries ONCE via the
|
| 639 |
+
// classic /chat path, reusing the same bubble — never a duplicate message.
|
| 640 |
+
function sendStream(text, t, attIds) {
|
| 641 |
+
var supported = false;
|
| 642 |
+
try { supported = !!(window.fetch && window.ReadableStream && window.TextDecoder); } catch (e) {}
|
| 643 |
+
if (!supported) { sendClassic(text, t, attIds); return; }
|
| 644 |
+
var settled = false;
|
| 645 |
+
function fallback() {
|
| 646 |
+
if (settled) return;
|
| 647 |
+
settled = true;
|
| 648 |
+
showTypingDots(t);
|
| 649 |
+
sendClassic(text, t, attIds);
|
| 650 |
+
}
|
| 651 |
+
fetch(BACKEND + "/chat/stream" + QS, {
|
| 652 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 653 |
+
body: JSON.stringify({ message: text, session_id: getSid(), attachment_ids: attIds || [] })
|
| 654 |
+
}).then(function (r) {
|
| 655 |
+
if (!r.ok || !r.body || !r.body.getReader) throw new Error("no-stream");
|
| 656 |
+
var reader = r.body.getReader();
|
| 657 |
+
var dec = new TextDecoder();
|
| 658 |
+
var buf = "";
|
| 659 |
+
function frame(raw) {
|
| 660 |
+
if (settled) return;
|
| 661 |
+
var ev = "", data = "", lines = raw.split("\n");
|
| 662 |
+
for (var i = 0; i < lines.length; i++) {
|
| 663 |
+
var ln = lines[i].replace(/\r$/, "");
|
| 664 |
+
if (ln.indexOf("event:") === 0) ev = ln.slice(6).replace(/^\s+/, "");
|
| 665 |
+
else if (ln.indexOf("data:") === 0) data += ln.slice(5).replace(/^\s+/, "");
|
| 666 |
+
}
|
| 667 |
+
if (ev === "stage") {
|
| 668 |
+
var key = "";
|
| 669 |
+
try { key = JSON.parse(data).key || ""; } catch (e) {}
|
| 670 |
+
showStage(t, key);
|
| 671 |
+
} else if (ev === "final") {
|
| 672 |
+
var d = JSON.parse(data); // a broken payload throws -> fallback
|
| 673 |
+
settled = true;
|
| 674 |
+
deliverReply(t, d);
|
| 675 |
+
try { reader.cancel(); } catch (e) {}
|
| 676 |
+
} else if (ev === "error") {
|
| 677 |
+
throw new Error("stream-error");
|
| 678 |
+
}
|
| 679 |
+
}
|
| 680 |
+
function pump() {
|
| 681 |
+
return reader.read().then(function (res) {
|
| 682 |
+
if (settled) return;
|
| 683 |
+
if (res.done) throw new Error("eof-before-final");
|
| 684 |
+
buf += dec.decode(res.value, { stream: true });
|
| 685 |
+
var cut;
|
| 686 |
+
while (!settled && (cut = buf.indexOf("\n\n")) !== -1) {
|
| 687 |
+
var raw = buf.slice(0, cut); buf = buf.slice(cut + 2);
|
| 688 |
+
if (raw) frame(raw);
|
| 689 |
+
}
|
| 690 |
+
if (!settled) return pump();
|
| 691 |
+
});
|
| 692 |
+
}
|
| 693 |
+
return pump();
|
| 694 |
+
}).catch(fallback);
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
function sendText(text) {
|
| 698 |
+
text = (text || "").trim(); if (!text) return;
|
| 699 |
+
var att = takeAttachments(); // chips are consumed by THIS message
|
| 700 |
+
userMsg(text, att.names); input.value = "";
|
| 701 |
+
var t = typing();
|
| 702 |
+
sendStream(text, t, att.ids);
|
| 703 |
+
}
|
| 704 |
form.addEventListener("submit", function (e) { e.preventDefault(); sendText(input.value); });
|
| 705 |
|
| 706 |
// Proactive nudge: after a bit, invite the visitor (once) if chat unopened.
|
|
|
|
| 710 |
b.textContent = T.bubble;
|
| 711 |
b.addEventListener("click", toggle);
|
| 712 |
document.body.appendChild(b);
|
| 713 |
+
notify(); // the proactive bubble counts as one unread message
|
| 714 |
setTimeout(function () { var x = document.getElementById("ssb-bubble"); if (x) x.remove(); }, 18000);
|
| 715 |
}, 22000);
|
| 716 |
|
|
|
|
| 739 |
}).then(function (r) { return r.json(); }).then(function () {
|
| 740 |
box.remove();
|
| 741 |
botMsg(T.hfThanks);
|
| 742 |
+
notify();
|
| 743 |
}).catch(function () {
|
| 744 |
botMsg(T.hfError);
|
| 745 |
+
notify();
|
| 746 |
});
|
| 747 |
});
|
| 748 |
}
|
app/tenancy.py
CHANGED
|
@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
| 14 |
|
| 15 |
from app.crypto import decrypt, encrypt
|
| 16 |
from app.models import (
|
|
|
|
| 17 |
ChatMessage,
|
| 18 |
ChatSession,
|
| 19 |
Event,
|
|
@@ -133,6 +134,8 @@ async def purge_tenant(db: AsyncSession, tenant: Tenant) -> None:
|
|
| 133 |
select(ChatSession.id).where(ChatSession.tenant_id == tenant.id)
|
| 134 |
)
|
| 135 |
))
|
|
|
|
|
|
|
| 136 |
await db.execute(delete(ChatSession).where(ChatSession.tenant_id == tenant.id))
|
| 137 |
await db.execute(delete(HandoffRequest).where(HandoffRequest.tenant_id == tenant.id))
|
| 138 |
await db.execute(delete(Event).where(Event.tenant_id == tenant.id))
|
|
|
|
| 14 |
|
| 15 |
from app.crypto import decrypt, encrypt
|
| 16 |
from app.models import (
|
| 17 |
+
Attachment,
|
| 18 |
ChatMessage,
|
| 19 |
ChatSession,
|
| 20 |
Event,
|
|
|
|
| 134 |
select(ChatSession.id).where(ChatSession.tenant_id == tenant.id)
|
| 135 |
)
|
| 136 |
))
|
| 137 |
+
# Visitor uploads (already text-only) — covers admin delete AND demo expiry.
|
| 138 |
+
await db.execute(delete(Attachment).where(Attachment.tenant_id == tenant.id))
|
| 139 |
await db.execute(delete(ChatSession).where(ChatSession.tenant_id == tenant.id))
|
| 140 |
await db.execute(delete(HandoffRequest).where(HandoffRequest.tenant_id == tenant.id))
|
| 141 |
await db.execute(delete(Event).where(Event.tenant_id == tenant.id))
|
app/vision.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Describe customer-uploaded photos with a free Groq vision model.
|
| 2 |
+
|
| 3 |
+
The visitor's image binary is NEVER persisted: at upload time it is turned
|
| 4 |
+
into a detailed Spanish text description here, and only that text is stored
|
| 5 |
+
(scoped to one tenant + one chat session). Any failure — no key, network
|
| 6 |
+
error, empty answer — returns None so the upload endpoint can degrade to a
|
| 7 |
+
friendly placeholder instead of failing the request.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import base64
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
|
| 17 |
+
from app.config import get_settings
|
| 18 |
+
|
| 19 |
+
log = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
|
| 22 |
+
VISION_TIMEOUT = 30.0
|
| 23 |
+
VISION_MAX_TOKENS = 500
|
| 24 |
+
_PROMPT = (
|
| 25 |
+
"Describe esta imagen en español de forma detallada y factual para el "
|
| 26 |
+
"asistente de una tienda: objetos que aparecen, texto visible, daños o "
|
| 27 |
+
"defectos, marca, modelo o referencias visibles. Describe SOLO lo que se "
|
| 28 |
+
"ve, sin inventar nada."
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _groq_keys() -> list[str]:
|
| 33 |
+
"""Same key sources as the LLM router (app/llm/router.py): GROQ_API_KEY
|
| 34 |
+
plus GROQ_API_KEYS, de-duplicated, preserving order."""
|
| 35 |
+
settings = get_settings()
|
| 36 |
+
keys: list[str] = []
|
| 37 |
+
for key in [settings.groq_api_key, *settings.groq_api_keys]:
|
| 38 |
+
key = (key or "").strip()
|
| 39 |
+
if key and key not in keys:
|
| 40 |
+
keys.append(key)
|
| 41 |
+
return keys
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def describe_image(data: bytes, mime: str) -> str | None:
|
| 45 |
+
"""A detailed factual Spanish description of the image, or None on ANY
|
| 46 |
+
failure (the caller stores a placeholder and the bot apologizes naturally)."""
|
| 47 |
+
keys = _groq_keys()
|
| 48 |
+
if not keys:
|
| 49 |
+
return None
|
| 50 |
+
payload = {
|
| 51 |
+
"model": VISION_MODEL,
|
| 52 |
+
"temperature": 0.2,
|
| 53 |
+
"max_tokens": VISION_MAX_TOKENS,
|
| 54 |
+
"messages": [
|
| 55 |
+
{
|
| 56 |
+
"role": "user",
|
| 57 |
+
"content": [
|
| 58 |
+
{"type": "text", "text": _PROMPT},
|
| 59 |
+
{
|
| 60 |
+
"type": "image_url",
|
| 61 |
+
"image_url": {
|
| 62 |
+
"url": "data:"
|
| 63 |
+
+ mime
|
| 64 |
+
+ ";base64,"
|
| 65 |
+
+ base64.b64encode(data).decode("ascii")
|
| 66 |
+
},
|
| 67 |
+
},
|
| 68 |
+
],
|
| 69 |
+
}
|
| 70 |
+
],
|
| 71 |
+
}
|
| 72 |
+
url = get_settings().groq_base_url.rstrip("/") + "/chat/completions"
|
| 73 |
+
for key in keys: # one try per key: a rate-limited key fails over to the next
|
| 74 |
+
try:
|
| 75 |
+
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
|
| 76 |
+
resp = await client.post(
|
| 77 |
+
url, json=payload, headers={"Authorization": f"Bearer {key}"}
|
| 78 |
+
)
|
| 79 |
+
resp.raise_for_status()
|
| 80 |
+
content = (resp.json()["choices"][0]["message"]["content"] or "").strip()
|
| 81 |
+
if content:
|
| 82 |
+
return content
|
| 83 |
+
except Exception: # noqa: BLE001 - vision is best-effort by design
|
| 84 |
+
log.warning("vision describe_image attempt failed", exc_info=True)
|
| 85 |
+
return None
|
migrations/versions/0020_attachments.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""attachments table: per-session visitor uploads converted to text
|
| 2 |
+
|
| 3 |
+
Revision ID: 0020_attachments
|
| 4 |
+
Revises: 0019_demo_tenants
|
| 5 |
+
Create Date: 2026-06-11
|
| 6 |
+
|
| 7 |
+
Additive, idempotent on Postgres (CREATE TABLE IF NOT EXISTS). On other
|
| 8 |
+
dialects it is a no-op (tests/dev use create_all which already has the table).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from alembic import op
|
| 14 |
+
|
| 15 |
+
revision = "0020_attachments"
|
| 16 |
+
down_revision = "0019_demo_tenants"
|
| 17 |
+
branch_labels = None
|
| 18 |
+
depends_on = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def upgrade() -> None:
|
| 22 |
+
if op.get_bind().dialect.name != "postgresql":
|
| 23 |
+
return
|
| 24 |
+
op.execute(
|
| 25 |
+
"""
|
| 26 |
+
CREATE TABLE IF NOT EXISTS attachments (
|
| 27 |
+
id VARCHAR(40) PRIMARY KEY,
|
| 28 |
+
tenant_id INTEGER REFERENCES tenants(id) ON DELETE CASCADE,
|
| 29 |
+
session_id VARCHAR(40),
|
| 30 |
+
filename VARCHAR(255),
|
| 31 |
+
mime VARCHAR(60),
|
| 32 |
+
text TEXT,
|
| 33 |
+
created_at TIMESTAMPTZ
|
| 34 |
+
)
|
| 35 |
+
"""
|
| 36 |
+
)
|
| 37 |
+
op.execute("CREATE INDEX IF NOT EXISTS ix_attachments_tenant_id ON attachments (tenant_id)")
|
| 38 |
+
op.execute(
|
| 39 |
+
"CREATE INDEX IF NOT EXISTS ix_attachments_session_id ON attachments (session_id)"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def downgrade() -> None:
|
| 44 |
+
if op.get_bind().dialect.name == "postgresql":
|
| 45 |
+
op.execute("DROP TABLE IF EXISTS attachments")
|
tests/routes/test_chat_stream.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""POST /chat/stream — SSE progress events + final ChatResponse.
|
| 2 |
+
|
| 3 |
+
The stream is sugar on top of the classic POST /chat: same tenant resolution,
|
| 4 |
+
same kill-switch gates, and a 'final' event that carries the exact ChatResponse
|
| 5 |
+
JSON. Every test proves a POSITIVE outcome first (the stream/classic path works
|
| 6 |
+
through the very fake router under test) before asserting the refusal/fallback.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import json
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
from sqlalchemy import func, select
|
| 16 |
+
|
| 17 |
+
from app.config import get_settings
|
| 18 |
+
from app.deps import get_router
|
| 19 |
+
from app.llm.base import ChatResult, ToolCall
|
| 20 |
+
from app.models import ChatSession
|
| 21 |
+
from app.orchestrator import run_turn
|
| 22 |
+
from app.routes.chat import _stream_events
|
| 23 |
+
from app.tools.registry import ToolContext
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ScriptRouter:
|
| 27 |
+
"""Plays a fixed script of ChatResults (same shape as other route tests)."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, results):
|
| 30 |
+
self._results = list(results)
|
| 31 |
+
self.calls = 0
|
| 32 |
+
|
| 33 |
+
async def chat(self, messages, tools, tier="large", temperature=None, order=None, tool_choice=None, max_tokens=None):
|
| 34 |
+
self.calls += 1
|
| 35 |
+
return self._results.pop(0)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class BoomRouter:
|
| 39 |
+
"""Unexpected (non-ProviderError) crash inside the turn."""
|
| 40 |
+
|
| 41 |
+
async def chat(self, messages, tools, tier="large", temperature=None, order=None, tool_choice=None, max_tokens=None):
|
| 42 |
+
raise RuntimeError("boom")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _script():
|
| 46 |
+
"""One knowledge lookup, then a text answer — identical for both endpoints."""
|
| 47 |
+
return [
|
| 48 |
+
ChatResult(
|
| 49 |
+
content=None,
|
| 50 |
+
tool_calls=[ToolCall(id="t1", name="search_knowledge", arguments={"query": "envíos"})],
|
| 51 |
+
finish_reason="tool_calls",
|
| 52 |
+
),
|
| 53 |
+
ChatResult(content="Hacemos envíos gratis desde 199€.", tool_calls=[], finish_reason="stop"),
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@pytest.fixture(autouse=True)
|
| 58 |
+
def no_real_search(monkeypatch):
|
| 59 |
+
async def fake_search(session, query, k=4, *, tenant_id=None):
|
| 60 |
+
return []
|
| 61 |
+
|
| 62 |
+
from app.rag import index
|
| 63 |
+
|
| 64 |
+
monkeypatch.setattr(index, "search", fake_search)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _parse_sse(text: str) -> list[tuple[str, str]]:
|
| 68 |
+
"""Parse standard SSE framing into (event, data) pairs; comments ignored."""
|
| 69 |
+
events = []
|
| 70 |
+
for raw in text.split("\n\n"):
|
| 71 |
+
ev, data = "", ""
|
| 72 |
+
for line in raw.split("\n"):
|
| 73 |
+
if line.startswith("event:"):
|
| 74 |
+
ev = line[6:].strip()
|
| 75 |
+
elif line.startswith("data:"):
|
| 76 |
+
data += line[5:].strip()
|
| 77 |
+
if ev:
|
| 78 |
+
events.append((ev, data))
|
| 79 |
+
return events
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def _count_sessions(db) -> int:
|
| 83 |
+
return (await db.execute(select(func.count()).select_from(ChatSession))).scalar_one()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
async def test_stream_emits_stages_then_final_equal_to_classic_chat(app_client, tenant):
|
| 87 |
+
app, client = app_client
|
| 88 |
+
|
| 89 |
+
# classic path first: the source of truth for this exact conversation
|
| 90 |
+
app.dependency_overrides[get_router] = lambda: ScriptRouter(_script())
|
| 91 |
+
classic = await client.post("/chat?t=t1", json={"message": "hola"})
|
| 92 |
+
assert classic.status_code == 200
|
| 93 |
+
expected = classic.json()
|
| 94 |
+
assert expected["reply"] == "Hacemos envíos gratis desde 199€."
|
| 95 |
+
assert expected["used_tools"] == ["search_knowledge"]
|
| 96 |
+
|
| 97 |
+
# same fake-router conversation through the stream
|
| 98 |
+
app.dependency_overrides[get_router] = lambda: ScriptRouter(_script())
|
| 99 |
+
r = await client.post("/chat/stream?t=t1", json={"message": "hola"})
|
| 100 |
+
assert r.status_code == 200
|
| 101 |
+
assert r.headers["content-type"].startswith("text/event-stream")
|
| 102 |
+
|
| 103 |
+
events = _parse_sse(r.text)
|
| 104 |
+
names = [e for e, _ in events]
|
| 105 |
+
# real stage events, in execution order, before the final payload
|
| 106 |
+
stage_keys = [json.loads(d)["key"] for e, d in events if e == "stage"]
|
| 107 |
+
assert stage_keys == ["thinking", "tool:search_knowledge"]
|
| 108 |
+
assert names[-1] == "final"
|
| 109 |
+
assert "error" not in names
|
| 110 |
+
|
| 111 |
+
final = json.loads(events[-1][1])
|
| 112 |
+
# identical ChatResponse shape + identical conversation outcome
|
| 113 |
+
assert final["reply"] == expected["reply"]
|
| 114 |
+
assert final["used_tools"] == expected["used_tools"]
|
| 115 |
+
assert final["products"] == expected["products"]
|
| 116 |
+
assert final["handoff"] == expected["handoff"]
|
| 117 |
+
assert final["lang"] == expected["lang"]
|
| 118 |
+
assert final["provider"] == expected["provider"]
|
| 119 |
+
assert final["session_id"] # its own (new) session, but a real one
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
async def test_stream_persists_session_like_classic(app_client, db_session, tenant):
|
| 123 |
+
app, client = app_client
|
| 124 |
+
app.dependency_overrides[get_router] = lambda: ScriptRouter(_script())
|
| 125 |
+
|
| 126 |
+
before = await _count_sessions(db_session)
|
| 127 |
+
r = await client.post("/chat/stream?t=t1", json={"message": "hola"})
|
| 128 |
+
assert r.status_code == 200
|
| 129 |
+
final = json.loads(_parse_sse(r.text)[-1][1])
|
| 130 |
+
|
| 131 |
+
assert await _count_sessions(db_session) == before + 1
|
| 132 |
+
session = await db_session.get(ChatSession, final["session_id"])
|
| 133 |
+
assert session is not None and session.tenant_id == tenant.id
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
async def test_stream_unknown_slug_404_no_stream_no_llm(app_client, db_session, default_tenant):
|
| 137 |
+
"""KILL-SWITCH: an explicit unknown slug must 404 — never fall back to the
|
| 138 |
+
'default' tenant (which exists here, so a fallback bug would pass a 200)."""
|
| 139 |
+
app, client = app_client
|
| 140 |
+
fake = ScriptRouter(_script())
|
| 141 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 142 |
+
|
| 143 |
+
r = await client.post("/chat/stream?t=ghost", json={"message": "hola"})
|
| 144 |
+
assert r.status_code == 404
|
| 145 |
+
assert "text/event-stream" not in r.headers.get("content-type", "")
|
| 146 |
+
assert fake.calls == 0
|
| 147 |
+
assert await _count_sessions(db_session) == 0
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def test_stream_paused_tenant_403_before_any_llm_call(app_client, db_session, tenant):
|
| 151 |
+
app, client = app_client
|
| 152 |
+
fake = ScriptRouter(_script() + _script())
|
| 153 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 154 |
+
|
| 155 |
+
# alive: the stream works through this very fake router
|
| 156 |
+
ok = await client.post("/chat/stream?t=t1", json={"message": "hola"})
|
| 157 |
+
assert ok.status_code == 200
|
| 158 |
+
assert fake.calls == 2 # tool turn + answer turn
|
| 159 |
+
|
| 160 |
+
tenant.chatbot_enabled = False
|
| 161 |
+
await db_session.commit()
|
| 162 |
+
|
| 163 |
+
# paused: plain 403 (no SSE body), zero additional LLM calls or sessions
|
| 164 |
+
r = await client.post("/chat/stream?t=t1", json={"message": "hola"})
|
| 165 |
+
assert r.status_code == 403
|
| 166 |
+
assert "text/event-stream" not in r.headers.get("content-type", "")
|
| 167 |
+
assert fake.calls == 2
|
| 168 |
+
assert await _count_sessions(db_session) == 1
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
async def test_stream_error_inside_run_turn_emits_error_event(app_client, tenant):
|
| 172 |
+
"""An unexpected crash mid-turn must end the stream with an 'error' event
|
| 173 |
+
(the widget falls back to POST /chat) — not a hang and not a 'final'."""
|
| 174 |
+
app, client = app_client
|
| 175 |
+
app.dependency_overrides[get_router] = lambda: BoomRouter()
|
| 176 |
+
|
| 177 |
+
r = await client.post("/chat/stream?t=t1", json={"message": "hola"})
|
| 178 |
+
assert r.status_code == 200
|
| 179 |
+
events = _parse_sse(r.text)
|
| 180 |
+
names = [e for e, _ in events]
|
| 181 |
+
assert names[0] == "stage" # it really started working ("thinking")
|
| 182 |
+
assert "error" in names
|
| 183 |
+
assert "final" not in names
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
async def test_stream_client_disconnect_cancels_turn_no_orphan(db_session, tenant):
|
| 187 |
+
"""If the visitor closes the tab mid-turn, Starlette closes the SSE
|
| 188 |
+
generator: that close must CANCEL the in-flight run_turn task (no orphaned
|
| 189 |
+
LLM work burning quota) and must complete without deadlocking on the queue."""
|
| 190 |
+
|
| 191 |
+
class HangRouter:
|
| 192 |
+
"""Blocks forever inside the turn; records whether it was cancelled."""
|
| 193 |
+
|
| 194 |
+
def __init__(self):
|
| 195 |
+
self.started = asyncio.Event()
|
| 196 |
+
self.cancelled = False
|
| 197 |
+
|
| 198 |
+
async def chat(self, messages, tools, tier="large", temperature=None, order=None, tool_choice=None, max_tokens=None):
|
| 199 |
+
self.started.set()
|
| 200 |
+
try:
|
| 201 |
+
await asyncio.Event().wait() # never set: hangs until cancelled
|
| 202 |
+
except asyncio.CancelledError:
|
| 203 |
+
self.cancelled = True
|
| 204 |
+
raise
|
| 205 |
+
|
| 206 |
+
router = HangRouter()
|
| 207 |
+
agen = _stream_events(
|
| 208 |
+
db=db_session, router_=router, settings=get_settings(),
|
| 209 |
+
tenant=tenant, message="hola", session_id=None,
|
| 210 |
+
)
|
| 211 |
+
first = await asyncio.wait_for(agen.__anext__(), timeout=5)
|
| 212 |
+
assert "thinking" in first # the turn really started and streamed a stage
|
| 213 |
+
await asyncio.wait_for(router.started.wait(), timeout=5) # LLM in flight
|
| 214 |
+
|
| 215 |
+
await asyncio.wait_for(agen.aclose(), timeout=5) # client disconnects
|
| 216 |
+
|
| 217 |
+
assert router.cancelled is True # the LLM task was cancelled, not orphaned
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
async def test_run_turn_emits_stages_in_order(db_session):
|
| 221 |
+
router = ScriptRouter(_script())
|
| 222 |
+
s = ChatSession(shop="x")
|
| 223 |
+
db_session.add(s)
|
| 224 |
+
await db_session.flush()
|
| 225 |
+
stages: list[str] = []
|
| 226 |
+
|
| 227 |
+
ctx = ToolContext(db=db_session, session=s)
|
| 228 |
+
resp = await run_turn(router, ctx, "hola", on_stage=stages.append)
|
| 229 |
+
|
| 230 |
+
assert resp.reply == "Hacemos envíos gratis desde 199€."
|
| 231 |
+
assert stages == ["thinking", "tool:search_knowledge"]
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
async def test_run_turn_raising_callback_does_not_break_reply(db_session):
|
| 235 |
+
router = ScriptRouter(_script())
|
| 236 |
+
s = ChatSession(shop="x")
|
| 237 |
+
db_session.add(s)
|
| 238 |
+
await db_session.flush()
|
| 239 |
+
seen: list[str] = []
|
| 240 |
+
|
| 241 |
+
def bad_callback(key: str) -> None:
|
| 242 |
+
seen.append(key) # prove it WAS invoked for every stage...
|
| 243 |
+
raise RuntimeError("ui hook exploded") # ...and always blew up
|
| 244 |
+
|
| 245 |
+
ctx = ToolContext(db=db_session, session=s)
|
| 246 |
+
resp = await run_turn(router, ctx, "hola", on_stage=bad_callback)
|
| 247 |
+
|
| 248 |
+
assert resp.reply == "Hacemos envíos gratis desde 199€."
|
| 249 |
+
assert resp.used_tools == ["search_knowledge"]
|
| 250 |
+
assert seen == ["thinking", "tool:search_knowledge"]
|
tests/routes/test_demo.py
CHANGED
|
@@ -183,6 +183,73 @@ async def test_per_ip_rate_limit_blocks_fourth_start(app_client):
|
|
| 183 |
assert other.status_code == 200
|
| 184 |
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
async def test_global_daily_cap(app_client, db_session):
|
| 187 |
from app.store import get_config, upsert_config
|
| 188 |
|
|
|
|
| 183 |
assert other.status_code == 200
|
| 184 |
|
| 185 |
|
| 186 |
+
async def test_rejected_start_does_not_consume_ip_quota(app_client):
|
| 187 |
+
"""Rejections are free: 3 invalid/SSRF attempts from one IP must not burn
|
| 188 |
+
the visitor's 3/hour budget — three VALID starts still succeed after them,
|
| 189 |
+
and only then does the cap kick in."""
|
| 190 |
+
_app, client = app_client
|
| 191 |
+
ip = "198.51.100.30"
|
| 192 |
+
for _ in range(3):
|
| 193 |
+
rejected = await _start(client, url="http://localhost:8000/admin", ip=ip)
|
| 194 |
+
assert rejected.status_code == 422
|
| 195 |
+
for _ in range(3):
|
| 196 |
+
ok = await _start(client, ip=ip)
|
| 197 |
+
assert ok.status_code == 200 # the full quota was still available
|
| 198 |
+
fourth = await _start(client, ip=ip)
|
| 199 |
+
assert fourth.status_code == 429 # successful starts stay capped at 3
|
| 200 |
+
assert "hora" in fourth.json()["detail"]
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
async def test_admin_token_bypasses_per_ip_daily_and_active_caps(
|
| 204 |
+
app_client, db_session, monkeypatch
|
| 205 |
+
):
|
| 206 |
+
"""X-Admin-Token == settings.admin_token skips every cap (founder testing
|
| 207 |
+
from his own IP) and consumes no visitor quota."""
|
| 208 |
+
from app.ratelimit import RateLimiter
|
| 209 |
+
from app.store import get_config, upsert_config
|
| 210 |
+
|
| 211 |
+
_app, client = app_client
|
| 212 |
+
# Every cap at "full": zero per-IP slots, daily counter at the cap, and an
|
| 213 |
+
# active-demo ceiling of zero.
|
| 214 |
+
monkeypatch.setattr(demo, "_ip_limiter", RateLimiter(0, 3600))
|
| 215 |
+
monkeypatch.setattr(demo, "DEMO_DAILY_CAP", 5)
|
| 216 |
+
monkeypatch.setattr(demo, "DEMO_MAX_ACTIVE", 0)
|
| 217 |
+
today = datetime.now(UTC).date().isoformat()
|
| 218 |
+
await upsert_config(db_session, "demo_daily", {"date": today, "count": 5})
|
| 219 |
+
await db_session.commit()
|
| 220 |
+
|
| 221 |
+
# Positive control: a normal visitor is blocked outright.
|
| 222 |
+
blocked = await _start(client, ip="198.51.100.40")
|
| 223 |
+
assert blocked.status_code == 429
|
| 224 |
+
|
| 225 |
+
r = await client.post(
|
| 226 |
+
"/demo/start",
|
| 227 |
+
json={"url": "https://mitienda.com", "email": ""},
|
| 228 |
+
headers={**_xff("198.51.100.40"), "X-Admin-Token": "test-admin-token"},
|
| 229 |
+
)
|
| 230 |
+
assert r.status_code == 200
|
| 231 |
+
slug = r.json()["slug"]
|
| 232 |
+
await demo._drain_bg()
|
| 233 |
+
status = (await client.get(f"/demo/status?d={slug}")).json()
|
| 234 |
+
assert status["stage"] == "listo" # a real, fully indexed demo
|
| 235 |
+
# The founder's test did not burn the visitors' daily budget either.
|
| 236 |
+
assert await get_config(db_session, "demo_daily") == {"date": today, "count": 5}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
async def test_wrong_admin_token_does_not_bypass(app_client, monkeypatch):
|
| 240 |
+
from app.ratelimit import RateLimiter
|
| 241 |
+
|
| 242 |
+
_app, client = app_client
|
| 243 |
+
monkeypatch.setattr(demo, "_ip_limiter", RateLimiter(0, 3600))
|
| 244 |
+
r = await client.post(
|
| 245 |
+
"/demo/start",
|
| 246 |
+
json={"url": "https://mitienda.com", "email": ""},
|
| 247 |
+
headers={**_xff("198.51.100.50"), "X-Admin-Token": "wrong-token"},
|
| 248 |
+
)
|
| 249 |
+
assert r.status_code == 429
|
| 250 |
+
assert "hora" in r.json()["detail"]
|
| 251 |
+
|
| 252 |
+
|
| 253 |
async def test_global_daily_cap(app_client, db_session):
|
| 254 |
from app.store import get_config, upsert_config
|
| 255 |
|
tests/routes/test_killswitch.py
CHANGED
|
@@ -120,6 +120,115 @@ async def test_apps_chat_deleted_slug_404_even_with_valid_signature(app_client,
|
|
| 120 |
assert await _count(db_session, ChatSession) == 0
|
| 121 |
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
async def test_handoff_deleted_slug_404_zero_rows(app_client, db_session):
|
| 124 |
_app, client = app_client
|
| 125 |
await _setup_default_and(client, "borrada")
|
|
|
|
| 120 |
assert await _count(db_session, ChatSession) == 0
|
| 121 |
|
| 122 |
|
| 123 |
+
async def test_chat_stream_deleted_slug_404_no_stream_no_llm_call(app_client, db_session):
|
| 124 |
+
"""/chat/stream shares _resolve_tenant: a deleted slug is a plain 404
|
| 125 |
+
BEFORE any SSE stream starts — never a fallback to 'default'."""
|
| 126 |
+
app, client = app_client
|
| 127 |
+
fake = RecordingRouter()
|
| 128 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 129 |
+
await _setup_default_and(client, "borrada")
|
| 130 |
+
|
| 131 |
+
# alive: the stream really completes through this very fake router
|
| 132 |
+
ok = await client.post("/chat/stream?t=borrada", json={"message": "hola"})
|
| 133 |
+
assert ok.status_code == 200
|
| 134 |
+
assert "event: final" in ok.text
|
| 135 |
+
assert len(fake.calls) == 1
|
| 136 |
+
|
| 137 |
+
await _delete(client, "borrada")
|
| 138 |
+
|
| 139 |
+
r = await client.post("/chat/stream?t=borrada", json={"message": "hola"})
|
| 140 |
+
assert r.status_code == 404
|
| 141 |
+
assert "text/event-stream" not in r.headers.get("content-type", "")
|
| 142 |
+
assert len(fake.calls) == 1 # the LLM was never invoked again
|
| 143 |
+
assert await _count(db_session, ChatSession) == 0
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
async def test_chat_stream_paused_tenant_403_no_llm_call(app_client, db_session):
|
| 147 |
+
app, client = app_client
|
| 148 |
+
fake = RecordingRouter()
|
| 149 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 150 |
+
r = await client.post("/admin/tenants", headers=AUTH, json={"slug": "morosa"})
|
| 151 |
+
assert r.status_code == 201
|
| 152 |
+
|
| 153 |
+
# enabled: the stream works
|
| 154 |
+
ok = await client.post("/chat/stream?t=morosa", json={"message": "hola"})
|
| 155 |
+
assert ok.status_code == 200 and "event: final" in ok.text
|
| 156 |
+
assert len(fake.calls) == 1
|
| 157 |
+
|
| 158 |
+
r = await client.put(
|
| 159 |
+
"/admin/tenants/morosa", headers=AUTH, json={"chatbot_enabled": False}
|
| 160 |
+
)
|
| 161 |
+
assert r.status_code == 200 and r.json()["chatbot_enabled"] is False
|
| 162 |
+
|
| 163 |
+
# paused: plain 403, no SSE body, zero additional LLM calls
|
| 164 |
+
r = await client.post("/chat/stream?t=morosa", json={"message": "hola"})
|
| 165 |
+
assert r.status_code == 403
|
| 166 |
+
assert "text/event-stream" not in r.headers.get("content-type", "")
|
| 167 |
+
assert len(fake.calls) == 1
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _pdf() -> bytes:
|
| 171 |
+
from fpdf import FPDF
|
| 172 |
+
|
| 173 |
+
pdf = FPDF()
|
| 174 |
+
pdf.add_page()
|
| 175 |
+
pdf.set_font("helvetica", size=12)
|
| 176 |
+
pdf.cell(0, 10, text="documento del visitante")
|
| 177 |
+
return bytes(pdf.output())
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
async def test_chat_upload_deleted_slug_404_and_attachments_wiped(app_client, db_session):
|
| 181 |
+
"""/chat/upload shares the same gates AND a deleted tenant's visitor
|
| 182 |
+
uploads are wiped with the rest of its data (admin delete -> purge)."""
|
| 183 |
+
from app.models import Attachment
|
| 184 |
+
|
| 185 |
+
_app, client = app_client
|
| 186 |
+
await _setup_default_and(client, "borrada")
|
| 187 |
+
|
| 188 |
+
# alive: the upload really lands
|
| 189 |
+
ok = await client.post(
|
| 190 |
+
"/chat/upload?t=borrada", files={"file": ("doc.pdf", _pdf(), "application/pdf")}
|
| 191 |
+
)
|
| 192 |
+
assert ok.status_code == 200
|
| 193 |
+
assert await _count(db_session, Attachment) == 1
|
| 194 |
+
|
| 195 |
+
await _delete(client, "borrada")
|
| 196 |
+
assert await _count(db_session, Attachment) == 0 # died with the tenant
|
| 197 |
+
|
| 198 |
+
# deleted: 404 (NOT a fallback to 'default'), nothing stored anywhere
|
| 199 |
+
r = await client.post(
|
| 200 |
+
"/chat/upload?t=borrada", files={"file": ("doc.pdf", _pdf(), "application/pdf")}
|
| 201 |
+
)
|
| 202 |
+
assert r.status_code == 404
|
| 203 |
+
assert await _count(db_session, Attachment) == 0
|
| 204 |
+
assert await _count(db_session, ChatSession) == 0
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
async def test_chat_upload_paused_tenant_403_nothing_stored(app_client, db_session):
|
| 208 |
+
from app.models import Attachment
|
| 209 |
+
|
| 210 |
+
_app, client = app_client
|
| 211 |
+
r = await client.post("/admin/tenants", headers=AUTH, json={"slug": "morosa"})
|
| 212 |
+
assert r.status_code == 201
|
| 213 |
+
|
| 214 |
+
ok = await client.post(
|
| 215 |
+
"/chat/upload?t=morosa", files={"file": ("doc.pdf", _pdf(), "application/pdf")}
|
| 216 |
+
)
|
| 217 |
+
assert ok.status_code == 200 # enabled: it works
|
| 218 |
+
assert await _count(db_session, Attachment) == 1
|
| 219 |
+
|
| 220 |
+
r = await client.put(
|
| 221 |
+
"/admin/tenants/morosa", headers=AUTH, json={"chatbot_enabled": False}
|
| 222 |
+
)
|
| 223 |
+
assert r.status_code == 200 and r.json()["chatbot_enabled"] is False
|
| 224 |
+
|
| 225 |
+
r = await client.post(
|
| 226 |
+
"/chat/upload?t=morosa", files={"file": ("doc2.pdf", _pdf(), "application/pdf")}
|
| 227 |
+
)
|
| 228 |
+
assert r.status_code == 403
|
| 229 |
+
assert await _count(db_session, Attachment) == 1 # only the pre-pause one
|
| 230 |
+
|
| 231 |
+
|
| 232 |
async def test_handoff_deleted_slug_404_zero_rows(app_client, db_session):
|
| 233 |
_app, client = app_client
|
| 234 |
await _setup_default_and(client, "borrada")
|
tests/routes/test_uploads.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""POST /chat/upload + attachment_ids on /chat — STRICT tenant+session isolation.
|
| 2 |
+
|
| 3 |
+
The point of this battery is the founder's #1 requirement: customer data must
|
| 4 |
+
never mix across tenants (or across conversations of the same tenant). Every
|
| 5 |
+
test proves a POSITIVE outcome first — the upload/chat really works through the
|
| 6 |
+
very fake router under test — before asserting the refusal or the silent drop.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from datetime import UTC, datetime, timedelta
|
| 12 |
+
|
| 13 |
+
import pytest_asyncio
|
| 14 |
+
from sqlalchemy import func, select
|
| 15 |
+
|
| 16 |
+
from app.deps import get_router
|
| 17 |
+
from app.llm.base import ChatResult
|
| 18 |
+
from app.models import Attachment, ChatMessage, ChatSession, Tenant
|
| 19 |
+
|
| 20 |
+
PDF_SECRET = "Factura 78901 garantia dos anios producto TOORX"
|
| 21 |
+
VISION_DESC = "Una zapatilla deportiva roja talla 42 con la caja visiblemente danada"
|
| 22 |
+
VISION_FALLBACK = "(imagen recibida pero no se ha podido analizar)"
|
| 23 |
+
|
| 24 |
+
# Real magic bytes; only the prefix matters (vision is mocked in image tests).
|
| 25 |
+
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
| 26 |
+
EXE_BYTES = b"MZ\x90\x00\x03\x00" + b"\x00" * 64
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class CaptureRouter:
|
| 30 |
+
"""Replies with fixed text and records every prompt run_turn sends."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, reply="Entendido, he leido el adjunto."):
|
| 33 |
+
self.reply = reply
|
| 34 |
+
self.prompts: list[list[dict]] = []
|
| 35 |
+
|
| 36 |
+
async def chat(self, messages, tools, tier="large", temperature=None, order=None, tool_choice=None, max_tokens=None):
|
| 37 |
+
self.prompts.append([dict(m) for m in messages])
|
| 38 |
+
return ChatResult(content=self.reply, tool_calls=[], finish_reason="stop")
|
| 39 |
+
|
| 40 |
+
def all_user_text(self) -> str:
|
| 41 |
+
return "\n".join(
|
| 42 |
+
m["content"] for p in self.prompts for m in p if m["role"] == "user"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@pytest_asyncio.fixture
|
| 47 |
+
async def tenant_b(db_session):
|
| 48 |
+
"""A second committed tenant (slug 't2') for cross-tenant isolation tests."""
|
| 49 |
+
t = Tenant(slug="t2", name="Tienda 2", brand_name="Asistente T2")
|
| 50 |
+
db_session.add(t)
|
| 51 |
+
await db_session.commit()
|
| 52 |
+
return t
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _pdf_bytes(text: str = PDF_SECRET) -> bytes:
|
| 56 |
+
from fpdf import FPDF
|
| 57 |
+
|
| 58 |
+
pdf = FPDF()
|
| 59 |
+
pdf.add_page()
|
| 60 |
+
pdf.set_font("helvetica", size=12)
|
| 61 |
+
pdf.cell(0, 10, text=text)
|
| 62 |
+
return bytes(pdf.output())
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def _upload(client, slug, filename, content, mime="application/octet-stream", session_id=None):
|
| 66 |
+
data = {} if session_id is None else {"session_id": session_id}
|
| 67 |
+
return await client.post(
|
| 68 |
+
f"/chat/upload?t={slug}", files={"file": (filename, content, mime)}, data=data
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
async def _count_attachments(db) -> int:
|
| 73 |
+
return (await db.execute(select(func.count()).select_from(Attachment))).scalar_one()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# --- (a) happy path: PDF -> text -> injected into the LLM prompt -----------
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def test_pdf_upload_extracts_text_and_binds_to_tenant_session(app_client, db_session, tenant):
|
| 80 |
+
app, client = app_client
|
| 81 |
+
|
| 82 |
+
r = await _upload(client, "t1", "manual.pdf", _pdf_bytes(), "application/pdf")
|
| 83 |
+
assert r.status_code == 200
|
| 84 |
+
up = r.json()
|
| 85 |
+
assert up["kind"] == "pdf"
|
| 86 |
+
assert up["filename"] == "manual.pdf"
|
| 87 |
+
assert "78901" in up["preview"]
|
| 88 |
+
assert up["session_id"]
|
| 89 |
+
|
| 90 |
+
att = await db_session.get(Attachment, up["attachment_id"])
|
| 91 |
+
assert att is not None
|
| 92 |
+
assert att.tenant_id == tenant.id
|
| 93 |
+
assert att.session_id == up["session_id"]
|
| 94 |
+
assert "78901" in att.text
|
| 95 |
+
assert att.mime == "application/pdf"
|
| 96 |
+
|
| 97 |
+
# the session really exists and belongs to the tenant (same one /chat uses)
|
| 98 |
+
session = await db_session.get(ChatSession, up["session_id"])
|
| 99 |
+
assert session is not None and session.tenant_id == tenant.id
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
async def test_chat_with_attachment_id_injects_text_into_prompt(app_client, db_session, tenant):
|
| 103 |
+
app, client = app_client
|
| 104 |
+
fake = CaptureRouter()
|
| 105 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 106 |
+
|
| 107 |
+
up = (await _upload(client, "t1", "manual.pdf", _pdf_bytes(), "application/pdf")).json()
|
| 108 |
+
sid = up["session_id"]
|
| 109 |
+
|
| 110 |
+
r = await client.post(
|
| 111 |
+
"/chat?t=t1",
|
| 112 |
+
json={
|
| 113 |
+
"message": "que numero de factura aparece?",
|
| 114 |
+
"session_id": sid,
|
| 115 |
+
"attachment_ids": [up["attachment_id"]],
|
| 116 |
+
},
|
| 117 |
+
)
|
| 118 |
+
assert r.status_code == 200
|
| 119 |
+
body = r.json()
|
| 120 |
+
assert body["reply"] == fake.reply # the turn really flowed
|
| 121 |
+
assert body["session_id"] == sid # same session the attachment is bound to
|
| 122 |
+
|
| 123 |
+
prompt = fake.all_user_text()
|
| 124 |
+
assert "[El cliente ha adjuntado manual.pdf]" in prompt
|
| 125 |
+
assert "78901" in prompt
|
| 126 |
+
assert "que numero de factura aparece?" in prompt
|
| 127 |
+
|
| 128 |
+
# history replays coherently: the stored user message keeps the marker
|
| 129 |
+
msgs = (
|
| 130 |
+
await db_session.execute(
|
| 131 |
+
select(ChatMessage).where(
|
| 132 |
+
ChatMessage.session_id == sid, ChatMessage.role == "user"
|
| 133 |
+
)
|
| 134 |
+
)
|
| 135 |
+
).scalars().all()
|
| 136 |
+
assert any("[El cliente ha adjuntado manual.pdf]" in m.content for m in msgs)
|
| 137 |
+
assert any("78901" in m.content for m in msgs)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def test_chat_stream_carries_attachment_too(app_client, tenant):
|
| 141 |
+
app, client = app_client
|
| 142 |
+
fake = CaptureRouter()
|
| 143 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 144 |
+
|
| 145 |
+
up = (await _upload(client, "t1", "manual.pdf", _pdf_bytes(), "application/pdf")).json()
|
| 146 |
+
|
| 147 |
+
r = await client.post(
|
| 148 |
+
"/chat/stream?t=t1",
|
| 149 |
+
json={
|
| 150 |
+
"message": "resume el adjunto",
|
| 151 |
+
"session_id": up["session_id"],
|
| 152 |
+
"attachment_ids": [up["attachment_id"]],
|
| 153 |
+
},
|
| 154 |
+
)
|
| 155 |
+
assert r.status_code == 200
|
| 156 |
+
assert "event: final" in r.text # the stream really completed
|
| 157 |
+
assert fake.reply in r.text
|
| 158 |
+
prompt = fake.all_user_text()
|
| 159 |
+
assert "[El cliente ha adjuntado manual.pdf]" in prompt
|
| 160 |
+
assert "78901" in prompt
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# --- (b) image path: vision mocked, incl. graceful failure ------------------
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
async def test_image_upload_stores_vision_description(app_client, db_session, tenant, monkeypatch):
|
| 167 |
+
app, client = app_client
|
| 168 |
+
fake = CaptureRouter()
|
| 169 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 170 |
+
seen = {}
|
| 171 |
+
|
| 172 |
+
async def fake_vision(data, mime):
|
| 173 |
+
seen["mime"] = mime
|
| 174 |
+
seen["data"] = data
|
| 175 |
+
return VISION_DESC
|
| 176 |
+
|
| 177 |
+
monkeypatch.setattr("app.vision.describe_image", fake_vision)
|
| 178 |
+
|
| 179 |
+
r = await _upload(client, "t1", "foto.png", PNG_BYTES, "image/png")
|
| 180 |
+
assert r.status_code == 200
|
| 181 |
+
up = r.json()
|
| 182 |
+
assert up["kind"] == "image"
|
| 183 |
+
assert "zapatilla" in up["preview"]
|
| 184 |
+
assert seen["mime"] == "image/png" # mime came from MAGIC bytes
|
| 185 |
+
assert seen["data"] == PNG_BYTES # vision got the real binary
|
| 186 |
+
|
| 187 |
+
att = await db_session.get(Attachment, up["attachment_id"])
|
| 188 |
+
assert att is not None and att.text == VISION_DESC
|
| 189 |
+
|
| 190 |
+
# and the description reaches the LLM on the next message
|
| 191 |
+
r2 = await client.post(
|
| 192 |
+
"/chat?t=t1",
|
| 193 |
+
json={
|
| 194 |
+
"message": "este es el producto que recibi",
|
| 195 |
+
"session_id": up["session_id"],
|
| 196 |
+
"attachment_ids": [up["attachment_id"]],
|
| 197 |
+
},
|
| 198 |
+
)
|
| 199 |
+
assert r2.status_code == 200 and r2.json()["reply"] == fake.reply
|
| 200 |
+
assert VISION_DESC in fake.all_user_text()
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
async def test_image_vision_failure_falls_back_and_still_succeeds(app_client, db_session, tenant, monkeypatch):
|
| 204 |
+
app, client = app_client
|
| 205 |
+
fake = CaptureRouter()
|
| 206 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 207 |
+
|
| 208 |
+
async def broken_vision(data, mime):
|
| 209 |
+
return None
|
| 210 |
+
|
| 211 |
+
monkeypatch.setattr("app.vision.describe_image", broken_vision)
|
| 212 |
+
|
| 213 |
+
r = await _upload(client, "t1", "foto.jpg", b"\xff\xd8\xff\xe0" + b"\x00" * 32, "image/jpeg")
|
| 214 |
+
assert r.status_code == 200 # the upload still succeeds
|
| 215 |
+
up = r.json()
|
| 216 |
+
assert up["kind"] == "image"
|
| 217 |
+
|
| 218 |
+
att = await db_session.get(Attachment, up["attachment_id"])
|
| 219 |
+
assert att is not None and att.text == VISION_FALLBACK
|
| 220 |
+
|
| 221 |
+
r2 = await client.post(
|
| 222 |
+
"/chat?t=t1",
|
| 223 |
+
json={"message": "mira la foto", "session_id": up["session_id"],
|
| 224 |
+
"attachment_ids": [up["attachment_id"]]},
|
| 225 |
+
)
|
| 226 |
+
assert r2.status_code == 200 and r2.json()["reply"] == fake.reply
|
| 227 |
+
assert VISION_FALLBACK in fake.all_user_text() # the bot can apologize naturally
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# --- (c) ISOLATION: the whole point --------------------------------------
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
async def test_other_tenants_attachment_id_is_silently_dropped(app_client, db_session, tenant, tenant_b):
|
| 234 |
+
app, client = app_client
|
| 235 |
+
fake = CaptureRouter()
|
| 236 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 237 |
+
|
| 238 |
+
# tenant A uploads a document with a private marker
|
| 239 |
+
up = (await _upload(client, "t1", "privado.pdf", _pdf_bytes("Datos privados 555777"), "application/pdf")).json()
|
| 240 |
+
att = await db_session.get(Attachment, up["attachment_id"])
|
| 241 |
+
assert att is not None and "555777" in att.text # the secret IS on the server
|
| 242 |
+
|
| 243 |
+
# tenant B (its own fresh session) references tenant A's attachment id
|
| 244 |
+
r = await client.post(
|
| 245 |
+
"/chat?t=t2",
|
| 246 |
+
json={"message": "que pone en el documento?", "attachment_ids": [up["attachment_id"]]},
|
| 247 |
+
)
|
| 248 |
+
assert r.status_code == 200 # NEVER an error: existence is not revealed
|
| 249 |
+
assert r.json()["reply"] == fake.reply # the conversation flows normally
|
| 250 |
+
|
| 251 |
+
prompt = fake.all_user_text()
|
| 252 |
+
assert "que pone en el documento?" in prompt # the message DID reach the LLM
|
| 253 |
+
assert "555777" not in prompt # ...but tenant A's content did NOT
|
| 254 |
+
assert "[El cliente ha adjuntado" not in prompt
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
async def test_cross_tenant_with_stolen_session_id_still_drops(app_client, db_session, tenant, tenant_b):
|
| 258 |
+
"""WORST-CASE attacker: knows BOTH the victim's attachment id AND the
|
| 259 |
+
victim's session id, and replays them through another tenant's widget.
|
| 260 |
+
The foreign session id must not be adopted and the content must not leak."""
|
| 261 |
+
app, client = app_client
|
| 262 |
+
fake = CaptureRouter()
|
| 263 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 264 |
+
|
| 265 |
+
up = (await _upload(client, "t1", "privado.pdf", _pdf_bytes("Datos privados 555777"), "application/pdf")).json()
|
| 266 |
+
att = await db_session.get(Attachment, up["attachment_id"])
|
| 267 |
+
assert att is not None and "555777" in att.text # the secret IS on the server
|
| 268 |
+
|
| 269 |
+
r = await client.post(
|
| 270 |
+
"/chat?t=t2",
|
| 271 |
+
json={"message": "lee el adjunto", "session_id": up["session_id"],
|
| 272 |
+
"attachment_ids": [up["attachment_id"]]},
|
| 273 |
+
)
|
| 274 |
+
assert r.status_code == 200 # NEVER an error: existence is not revealed
|
| 275 |
+
assert r.json()["reply"] == fake.reply # the conversation flows normally
|
| 276 |
+
assert r.json()["session_id"] != up["session_id"] # foreign sid NOT adopted
|
| 277 |
+
|
| 278 |
+
prompt = fake.all_user_text()
|
| 279 |
+
assert "lee el adjunto" in prompt # the message DID reach the LLM
|
| 280 |
+
assert "555777" not in prompt # ...but the victim's content did NOT
|
| 281 |
+
assert "[El cliente ha adjuntado" not in prompt
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
async def test_stream_drops_cross_tenant_attachment_too(app_client, db_session, tenant, tenant_b):
|
| 285 |
+
"""/chat/stream applies the exact same (id, tenant, session) filter: the
|
| 286 |
+
stolen pair injects NOTHING there either, while the stream itself works."""
|
| 287 |
+
app, client = app_client
|
| 288 |
+
fake = CaptureRouter()
|
| 289 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 290 |
+
|
| 291 |
+
up = (await _upload(client, "t1", "privado.pdf", _pdf_bytes("Datos privados 555777"), "application/pdf")).json()
|
| 292 |
+
|
| 293 |
+
r = await client.post(
|
| 294 |
+
"/chat/stream?t=t2",
|
| 295 |
+
json={"message": "lee el adjunto", "session_id": up["session_id"],
|
| 296 |
+
"attachment_ids": [up["attachment_id"]]},
|
| 297 |
+
)
|
| 298 |
+
assert r.status_code == 200
|
| 299 |
+
assert "event: final" in r.text # the stream really completed
|
| 300 |
+
assert fake.reply in r.text # ...with a normal reply
|
| 301 |
+
|
| 302 |
+
prompt = fake.all_user_text()
|
| 303 |
+
assert "lee el adjunto" in prompt
|
| 304 |
+
assert "555777" not in prompt
|
| 305 |
+
assert "[El cliente ha adjuntado" not in prompt
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
async def test_same_tenant_different_session_is_silently_dropped(app_client, db_session, tenant):
|
| 309 |
+
app, client = app_client
|
| 310 |
+
fake = CaptureRouter()
|
| 311 |
+
app.dependency_overrides[get_router] = lambda: fake
|
| 312 |
+
|
| 313 |
+
up = (await _upload(client, "t1", "privado.pdf", _pdf_bytes("Datos privados 555777"), "application/pdf")).json()
|
| 314 |
+
|
| 315 |
+
# same tenant, but NO session_id -> a brand new conversation
|
| 316 |
+
r = await client.post(
|
| 317 |
+
"/chat?t=t1",
|
| 318 |
+
json={"message": "que pone en el documento?", "attachment_ids": [up["attachment_id"]]},
|
| 319 |
+
)
|
| 320 |
+
assert r.status_code == 200
|
| 321 |
+
assert r.json()["reply"] == fake.reply
|
| 322 |
+
assert r.json()["session_id"] != up["session_id"] # truly another session
|
| 323 |
+
|
| 324 |
+
prompt = fake.all_user_text()
|
| 325 |
+
assert "que pone en el documento?" in prompt
|
| 326 |
+
assert "555777" not in prompt
|
| 327 |
+
assert "[El cliente ha adjuntado" not in prompt
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# --- (d) gates --------------------------------------------------------------
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
async def test_upload_unknown_slug_404_never_falls_back(app_client, db_session, default_tenant):
|
| 334 |
+
app, client = app_client
|
| 335 |
+
|
| 336 |
+
r = await _upload(client, "ghost", "a.pdf", _pdf_bytes(), "application/pdf")
|
| 337 |
+
assert r.status_code == 404 # KILL-SWITCH: no fallback to 'default'
|
| 338 |
+
assert await _count_attachments(db_session) == 0
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
async def test_upload_paused_tenant_403(app_client, db_session, tenant):
|
| 342 |
+
app, client = app_client
|
| 343 |
+
|
| 344 |
+
ok = await _upload(client, "t1", "a.pdf", _pdf_bytes(), "application/pdf")
|
| 345 |
+
assert ok.status_code == 200 # alive first: the endpoint really works
|
| 346 |
+
|
| 347 |
+
tenant.chatbot_enabled = False
|
| 348 |
+
await db_session.commit()
|
| 349 |
+
|
| 350 |
+
r = await _upload(client, "t1", "b.pdf", _pdf_bytes(), "application/pdf")
|
| 351 |
+
assert r.status_code == 403
|
| 352 |
+
assert await _count_attachments(db_session) == 1 # only the pre-pause one
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
async def test_upload_fourth_attachment_rejected(app_client, db_session, tenant):
|
| 356 |
+
app, client = app_client
|
| 357 |
+
|
| 358 |
+
first = (await _upload(client, "t1", "a.pdf", _pdf_bytes(), "application/pdf")).json()
|
| 359 |
+
sid = first["session_id"]
|
| 360 |
+
for name in ("b.pdf", "c.pdf"):
|
| 361 |
+
ok = await _upload(client, "t1", name, _pdf_bytes(), "application/pdf", session_id=sid)
|
| 362 |
+
assert ok.status_code == 200 # 3 in total are accepted
|
| 363 |
+
|
| 364 |
+
r = await _upload(client, "t1", "d.pdf", _pdf_bytes(), "application/pdf", session_id=sid)
|
| 365 |
+
assert r.status_code == 422
|
| 366 |
+
assert "3 archivos" in r.json()["detail"] # Spanish, human-readable
|
| 367 |
+
assert await _count_attachments(db_session) == 3
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
async def test_upload_oversize_rejected_without_storing(app_client, db_session, tenant):
|
| 371 |
+
app, client = app_client
|
| 372 |
+
|
| 373 |
+
blob = b"%PDF-1.4 " + b"0" * (6 * 1024 * 1024) # valid magic, 6 MB body
|
| 374 |
+
r = await _upload(client, "t1", "big.pdf", blob, "application/pdf")
|
| 375 |
+
assert r.status_code in (413, 422)
|
| 376 |
+
assert await _count_attachments(db_session) == 0
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
async def test_upload_exe_magic_bytes_rejected(app_client, db_session, tenant):
|
| 380 |
+
app, client = app_client
|
| 381 |
+
|
| 382 |
+
# extension and Content-Type LIE; the magic bytes (MZ) tell the truth
|
| 383 |
+
r = await _upload(client, "t1", "factura.pdf", EXE_BYTES, "application/pdf")
|
| 384 |
+
assert r.status_code == 422
|
| 385 |
+
assert await _count_attachments(db_session) == 0
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
async def test_upload_pdf_without_text_rejected(app_client, db_session, tenant):
|
| 389 |
+
from fpdf import FPDF
|
| 390 |
+
|
| 391 |
+
app, client = app_client
|
| 392 |
+
pdf = FPDF()
|
| 393 |
+
pdf.add_page() # a page with no text at all
|
| 394 |
+
|
| 395 |
+
r = await _upload(client, "t1", "vacio.pdf", bytes(pdf.output()), "application/pdf")
|
| 396 |
+
assert r.status_code == 422
|
| 397 |
+
assert "texto legible" in r.json()["detail"]
|
| 398 |
+
assert await _count_attachments(db_session) == 0
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
# --- (e) purge: admin delete + retention -----------------------------------
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
async def test_purge_tenant_removes_its_attachments(app_client, db_session, tenant):
|
| 405 |
+
from app.tenancy import purge_tenant
|
| 406 |
+
|
| 407 |
+
app, client = app_client
|
| 408 |
+
up = (await _upload(client, "t1", "a.pdf", _pdf_bytes(), "application/pdf")).json()
|
| 409 |
+
assert await db_session.get(Attachment, up["attachment_id"]) is not None # exists BEFORE
|
| 410 |
+
|
| 411 |
+
await purge_tenant(db_session, tenant)
|
| 412 |
+
await db_session.commit()
|
| 413 |
+
|
| 414 |
+
assert await _count_attachments(db_session) == 0 # gone AFTER
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
async def test_retention_purge_removes_attachments_of_old_sessions(db_session, tenant):
|
| 418 |
+
from app.retention import purge_old_sessions
|
| 419 |
+
|
| 420 |
+
now = datetime.now(UTC)
|
| 421 |
+
old = ChatSession(shop="x", tenant_id=tenant.id, last_seen=now - timedelta(days=60))
|
| 422 |
+
recent = ChatSession(shop="x", tenant_id=tenant.id, last_seen=now)
|
| 423 |
+
db_session.add_all([old, recent])
|
| 424 |
+
await db_session.flush()
|
| 425 |
+
old_att = Attachment(tenant_id=tenant.id, session_id=old.id, filename="viejo.pdf",
|
| 426 |
+
mime="application/pdf", text="viejo")
|
| 427 |
+
new_att = Attachment(tenant_id=tenant.id, session_id=recent.id, filename="nuevo.pdf",
|
| 428 |
+
mime="application/pdf", text="nuevo")
|
| 429 |
+
db_session.add_all([old_att, new_att])
|
| 430 |
+
await db_session.flush()
|
| 431 |
+
assert await _count_attachments(db_session) == 2 # both exist BEFORE
|
| 432 |
+
|
| 433 |
+
purged = await purge_old_sessions(db_session, days=30)
|
| 434 |
+
assert purged == 1
|
| 435 |
+
|
| 436 |
+
remaining = (await db_session.execute(select(Attachment))).scalars().all()
|
| 437 |
+
assert [a.filename for a in remaining] == ["nuevo.pdf"] # old one died with its session
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
# --- (f) demo tenants work out of the box ----------------------------------
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
async def test_demo_tenant_can_upload(app_client, db_session):
|
| 444 |
+
app, client = app_client
|
| 445 |
+
demo = Tenant(
|
| 446 |
+
slug="demo-cafe1234", name="Demo cafe.com", brand_name="cafe.com",
|
| 447 |
+
is_demo=True, demo_expires_at=datetime.now(UTC) + timedelta(hours=48),
|
| 448 |
+
)
|
| 449 |
+
db_session.add(demo)
|
| 450 |
+
await db_session.commit()
|
| 451 |
+
|
| 452 |
+
r = await _upload(client, "demo-cafe1234", "carta.pdf", _pdf_bytes(), "application/pdf")
|
| 453 |
+
assert r.status_code == 200
|
| 454 |
+
up = r.json()
|
| 455 |
+
att = await db_session.get(Attachment, up["attachment_id"])
|
| 456 |
+
assert att is not None and att.tenant_id == demo.id
|
| 457 |
+
assert "78901" in att.text
|
tests/test_ratelimit.py
CHANGED
|
@@ -35,3 +35,27 @@ def test_keys_independent():
|
|
| 35 |
now = datetime(2026, 6, 9, tzinfo=UTC)
|
| 36 |
assert rl.allow("a", now) is True
|
| 37 |
assert rl.allow("b", now) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
now = datetime(2026, 6, 9, tzinfo=UTC)
|
| 36 |
assert rl.allow("a", now) is True
|
| 37 |
assert rl.allow("b", now) is True
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_would_allow_peeks_without_consuming():
|
| 41 |
+
rl = RateLimiter(max_hits=2, window_seconds=60)
|
| 42 |
+
now = datetime(2026, 6, 9, tzinfo=UTC)
|
| 43 |
+
# Peeking any number of times never spends quota...
|
| 44 |
+
for _ in range(10):
|
| 45 |
+
assert rl.would_allow("k", now) is True
|
| 46 |
+
# ...so both real slots are still available afterwards.
|
| 47 |
+
assert rl.allow("k", now) is True
|
| 48 |
+
assert rl.allow("k", now) is True
|
| 49 |
+
assert rl.would_allow("k", now) is False # and the peek sees the limit
|
| 50 |
+
assert rl.allow("k", now) is False
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_record_consumes_a_slot_and_window_expires():
|
| 54 |
+
rl = RateLimiter(max_hits=1, window_seconds=60)
|
| 55 |
+
now = datetime(2026, 6, 9, tzinfo=UTC)
|
| 56 |
+
assert rl.would_allow("k", now) is True
|
| 57 |
+
rl.record("k", now)
|
| 58 |
+
assert rl.would_allow("k", now) is False
|
| 59 |
+
assert rl.allow("k", now) is False
|
| 60 |
+
# The recorded hit ages out of the window like any allow() hit.
|
| 61 |
+
assert rl.would_allow("k", now + timedelta(seconds=61)) is True
|