Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions backend/route_policy_manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,6 @@ routes:
method: GET
path: /v1/config/api-keys
policy: *desktop_migration_policy
- route_type: http
method: GET
path: /v1/crisp/unread
policy: *desktop_migration_policy
- route_type: http
method: POST
path: /v1/proxy/gemini-stream/{path:path}
Expand Down
114 changes: 3 additions & 111 deletions backend/routers/desktop_screen_crisp.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,19 @@
import base64
import json
import logging
import os
import time
from typing import Any

import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field, field_validator

from database.screen_activity import normalize_screen_activity_timestamp, upsert_screen_activity
from database.vector_db import upsert_screen_activity_vectors
from utils.executors import critical_executor, db_executor, run_blocking
from utils.other.endpoints import get_current_user_uid, get_user
from utils.executors import db_executor, run_blocking
from utils.other.endpoints import get_current_user_uid
from utils.subscription import grants_cloud_screen_vectors
from utils.subscription import is_desktop_trial_paywalled
from testing.parity_pack_v0.live_capture import SurfaceParityCapture

logger = logging.getLogger(__name__)
router = APIRouter()
_SESSION_CACHE_TTL = 3600
_SESSION_CACHE_LIMIT = 50000
_session_cache: dict[str, tuple[str, float]] = {}


class ScreenActivityRow(BaseModel):
Expand Down Expand Up @@ -55,10 +47,6 @@ async def _authorized_desktop_user(uid: str = Depends(get_current_user_uid)) ->
return uid


def _empty_unread_response() -> dict[str, Any]:
return {"unread_count": 0, "messages": []}


def _parity_screen_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Keep screen capture text-only and bounded; never retain embeddings/video."""
return [
Expand All @@ -72,49 +60,6 @@ def _parity_screen_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
]


def _cached_session(email: str) -> str | None:
value = _session_cache.get(email)
if value and time.monotonic() - value[1] < _SESSION_CACHE_TTL:
return value[0]
_session_cache.pop(email, None)
return None


def _cache_session(email: str, session_id: str) -> None:
now = time.monotonic()
_session_cache[email] = (session_id, now)
expired = [key for key, (_, saved_at) in _session_cache.items() if now - saved_at >= _SESSION_CACHE_TTL]
for key in expired:
_session_cache.pop(key, None)
if len(_session_cache) > _SESSION_CACHE_LIMIT:
for key, _ in sorted(_session_cache.items(), key=lambda item: item[1][1])[
: len(_session_cache) - _SESSION_CACHE_LIMIT
]:
_session_cache.pop(key, None)


async def _crisp_get(url: str, headers: dict[str, str]) -> httpx.Response:
async with httpx.AsyncClient(timeout=10) as client:
return await client.get(url, headers=headers)


async def _find_session(email: str, website_id: str, headers: dict[str, str]) -> str | None:
for page in range(1, 6):
response = await _crisp_get(f"https://api.crisp.chat/v1/website/{website_id}/conversations/{page}", headers)
if not response.is_success:
return None
data = response.json().get("data") or []
if not data:
return None
for conversation in data:
candidate = (conversation.get("meta") or {}).get("email")
if isinstance(candidate, str) and candidate.lower() == email:
session_id = conversation.get("session_id")
if isinstance(session_id, str):
return session_id
return None


@router.post("/v1/screen-activity/sync")
async def sync_screen_activity(
request: ScreenActivitySyncRequest, uid: str = Depends(_authorized_desktop_user)
Expand Down Expand Up @@ -159,56 +104,3 @@ async def sync_screen_activity(
return response
finally:
parity_capture.persist()


@router.get("/v1/crisp/unread")
async def get_crisp_unread(
since: int = Query(default=0, ge=0), uid: str = Depends(get_current_user_uid)
) -> dict[str, Any]:
identifier = os.getenv("CRISP_PLUGIN_IDENTIFIER")
key = os.getenv("CRISP_PLUGIN_KEY")
website_id = os.getenv("CRISP_WEBSITE_ID")
if not identifier or not key or not website_id:
return _empty_unread_response()
try:
user = await run_blocking(critical_executor, get_user, uid)
except Exception:
logger.exception("Crisp user lookup failed for uid=%s", uid)
return _empty_unread_response()
email = getattr(user, "email", None)
if not isinstance(email, str) or not email:
return _empty_unread_response()
email = email.lower()
auth = base64.b64encode(f"{identifier}:{key}".encode()).decode()
headers = {"Authorization": f"Basic {auth}", "X-Crisp-Tier": "plugin"}
try:
session_id = _cached_session(email)
if not session_id:
session_id = await _find_session(email, website_id, headers)
if session_id:
_cache_session(email, session_id)
if not session_id:
return _empty_unread_response()
response = await _crisp_get(
f"https://api.crisp.chat/v1/website/{website_id}/conversation/{session_id}/messages", headers
)
if not response.is_success:
return _empty_unread_response()
messages = response.json().get("data") or []
operator_messages = [
{
"text": content if isinstance(content, str) else json.dumps(content, separators=(",", ":")),
"timestamp": timestamp,
"from": "operator",
}
for message in messages
if message.get("from") == "operator"
and message.get("type") == "text"
and isinstance((timestamp := message.get("timestamp")), int)
and timestamp > since
and (content := message.get("content")) is not None
]
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
logger.exception("Crisp unread request failed for uid=%s", uid)
raise HTTPException(status_code=502, detail="Crisp request failed") from None
return {"unread_count": len(operator_messages), "messages": operator_messages}
3 changes: 3 additions & 0 deletions backend/scripts/check_app_client_openapi_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
'/v1/agent/vm-status',
'/v1/agent/vm-ensure',
'/v1/agent/keepalive',
# Desktop Crisp client removed in macOS 0.12.162+; endpoint had no other
# callers. Screen-activity sync stays on the same router (#11429).
'/v1/crisp/unread',
}
)

Expand Down
48 changes: 4 additions & 44 deletions backend/tests/unit/test_desktop_screen_crisp.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ def make_client() -> TestClient:
return TestClient(app)


def test_crisp_unread_route_is_removed():
assert make_client().get("/v1/crisp/unread").status_code == 404


def _entitle(monkeypatch, entitled: bool = True) -> None:
"""Pin the screen-vector entitlement.

Expand Down Expand Up @@ -159,50 +163,6 @@ def test_screen_activity_vector_treats_canonical_naive_timestamp_as_utc(monkeypa
assert upserts[0]["vectors"][0]["metadata"]["timestamp"] == 1785024000


def test_crisp_unread_preserves_operator_text_shape(monkeypatch):
monkeypatch.setenv("CRISP_PLUGIN_IDENTIFIER", "identifier")
monkeypatch.setenv("CRISP_PLUGIN_KEY", "key")
monkeypatch.setenv("CRISP_WEBSITE_ID", "website")
desktop_screen_crisp._session_cache.clear()
monkeypatch.setattr(desktop_screen_crisp, "get_user", lambda uid: SimpleNamespace(email="User@Example.com"))
responses = iter(
[
SimpleNamespace(
is_success=True,
json=lambda: {"data": [{"session_id": "session", "meta": {"email": "user@example.com"}}]},
),
SimpleNamespace(
is_success=True,
json=lambda: {
"data": [
{"from": "operator", "type": "text", "timestamp": 11, "content": "hello"},
{"from": "user", "type": "text", "timestamp": 12, "content": "ignore"},
{"from": "operator", "type": "file", "timestamp": 13, "content": "ignore"},
]
},
),
]
)

async def crisp_get(url, headers):
return next(responses)

monkeypatch.setattr(desktop_screen_crisp, "_crisp_get", crisp_get)

response = make_client().get("/v1/crisp/unread?since=10")

assert response.status_code == 200
assert response.json() == {"unread_count": 1, "messages": [{"text": "hello", "timestamp": 11, "from": "operator"}]}


def test_crisp_unread_is_empty_when_unconfigured(monkeypatch):
monkeypatch.delenv("CRISP_PLUGIN_IDENTIFIER", raising=False)
monkeypatch.delenv("CRISP_PLUGIN_KEY", raising=False)
monkeypatch.delenv("CRISP_WEBSITE_ID", raising=False)

assert make_client().get("/v1/crisp/unread").json() == {"unread_count": 0, "messages": []}


@pytest.mark.asyncio
async def test_screen_activity_rejects_paywalled_desktop_user(monkeypatch):
async def run_blocking(_, function, *args):
Expand Down