victor34593993 commited on
Commit
ececf61
·
verified ·
1 Parent(s): cd1d8fb

widget UX batch

Browse files
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: { "Content-Type": "application/json" },
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
- Both share one handler. Order disclosure always requires email+order#
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 sqlalchemy import select
 
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
- async def _run_chat(
 
 
 
 
 
 
 
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
- ) -> ChatResponse:
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
- ctx = ToolContext(
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
- response = await run_turn(router_, ctx, message, brand_name=tenant.brand_name,
117
- instructions=tenant.custom_instructions)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if not _ip_limiter.allow(_client_ip(request)):
 
 
 
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 used_today >= DEMO_DAILY_CAP:
349
- raise HTTPException(status_code=429, detail=MSG_DAILY_LIMIT)
350
- active = (
351
- await db.execute(
352
- select(func.count()).select_from(Tenant).where(Tenant.is_demo.is_(True))
353
- )
354
- ).scalar_one()
355
- if active >= DEMO_MAX_ACTIVE:
356
- raise HTTPException(status_code=429, detail=MSG_ACTIVE_LIMIT)
 
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
- await upsert_config(db, _DAILY_KEY, {"date": today, "count": used_today + 1})
 
 
 
 
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 + '">&times;</button></div>' +
220
  '<div id="ssb-msgs"></div>' +
221
- '<form id="ssb-form"><input id="ssb-input" type="text" autocomplete="off" placeholder="' + T.input + '" />' +
 
 
 
 
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) { row("user").textContent = t; msgs.scrollTop = msgs.scrollHeight; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- function sendText(text) {
289
- text = (text || "").trim(); if (!text) return;
290
- userMsg(text); input.value = "";
291
- var t = typing();
292
- fetch(BACKEND + "/chat" + QS, {
293
- method: "POST", headers: { "Content-Type": "application/json" },
294
- body: JSON.stringify({ message: text, session_id: getSid() })
295
- }).then(function (r) { return r.json(); }).then(function (d) {
296
- setSid(d.session_id); t.classList.remove("ssb-typing"); t.innerHTML = md(d.reply || "…");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  renderCards(d.products);
298
  if (d.handoff) renderHandoffForm();
299
  msgs.scrollTop = msgs.scrollHeight;
 
 
 
 
 
 
 
 
 
 
 
300
  }).catch(function () {
301
- t.classList.remove("ssb-typing"); t.textContent = T.error;
 
 
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 + '">&times;</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