From f92da1d20c8147aaa173bf8173004d20d0a2bbfe Mon Sep 17 00:00:00 2001 From: kimi Date: Sat, 21 Mar 2026 10:30:02 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20/api/matrix/bark=20endpoint=20?= =?UTF-8?q?=E2=80=94=20HTTP=20fallback=20for=20bark=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/matrix/bark endpoint that accepts a visitor message and returns Timmy's bark response. This is the HTTP fallback for when WebSocket isn't available. Features: - Accepts JSON {text: str, visitor_id: str} - Returns JSON bark message in produce_bark() format - Uses existing _generate_bark() for response generation - Rate-limited to 1 request per 3 seconds per visitor_id - Returns 429 with Retry-After header if rate limited - Graceful fallback on LLM errors Refs #675 --- src/dashboard/routes/world.py | 85 ++++++++++++- tests/dashboard/test_world_api.py | 196 ++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 1 deletion(-) diff --git a/src/dashboard/routes/world.py b/src/dashboard/routes/world.py index 6863c141..77a7a9d9 100644 --- a/src/dashboard/routes/world.py +++ b/src/dashboard/routes/world.py @@ -28,9 +28,10 @@ from typing import Any import yaml from fastapi import APIRouter, WebSocket from fastapi.responses import JSONResponse +from pydantic import BaseModel from config import settings -from infrastructure.presence import serialize_presence +from infrastructure.presence import produce_bark, serialize_presence from timmy.workshop_state import PRESENCE_FILE logger = logging.getLogger(__name__) @@ -38,6 +39,88 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/world", tags=["world"]) matrix_router = APIRouter(prefix="/api/matrix", tags=["matrix"]) +# --------------------------------------------------------------------------- +# Matrix Bark Endpoint — HTTP fallback for bark messages +# --------------------------------------------------------------------------- + +# Rate limiting: 1 request per 3 seconds per visitor_id +_BARK_RATE_LIMIT_SECONDS = 3 +_bark_last_request: dict[str, float] = {} + + +class BarkRequest(BaseModel): + """Request body for POST /api/matrix/bark.""" + + text: str + visitor_id: str + + +@matrix_router.post("/bark") +async def post_matrix_bark(request: BarkRequest) -> JSONResponse: + """Generate a bark response for a visitor message. + + HTTP fallback for when WebSocket isn't available. The Matrix frontend + can POST a message and get Timmy's bark response back as JSON. + + Rate limited to 1 request per 3 seconds per visitor_id. + + Request body: + - text: The visitor's message text + - visitor_id: Unique identifier for the visitor (used for rate limiting) + + Returns: + - 200: Bark message in produce_bark() format + - 429: Rate limit exceeded (try again later) + - 422: Invalid request (missing/invalid fields) + """ + # Validate inputs + text = request.text.strip() if request.text else "" + visitor_id = request.visitor_id.strip() if request.visitor_id else "" + + if not text: + return JSONResponse( + status_code=422, + content={"error": "text is required"}, + ) + + if not visitor_id: + return JSONResponse( + status_code=422, + content={"error": "visitor_id is required"}, + ) + + # Rate limiting check + now = time.time() + last_request = _bark_last_request.get(visitor_id, 0) + time_since_last = now - last_request + + if time_since_last < _BARK_RATE_LIMIT_SECONDS: + retry_after = _BARK_RATE_LIMIT_SECONDS - time_since_last + return JSONResponse( + status_code=429, + content={"error": "Rate limit exceeded. Try again later."}, + headers={"Retry-After": str(int(retry_after) + 1)}, + ) + + # Record this request + _bark_last_request[visitor_id] = now + + # Generate bark response + try: + reply = await _generate_bark(text) + except Exception as exc: + logger.warning("Bark generation failed: %s", exc) + reply = "Hmm, my thoughts are a bit tangled right now." + + # Build bark response using produce_bark format + bark = produce_bark(agent_id="timmy", text=reply, style="speech") + + return JSONResponse( + content=bark, + headers={"Cache-Control": "no-cache, no-store"}, + ) + + # --------------------------------------------------------------------------- # Matrix Agent Registry — serves agents to the Matrix visualization # --------------------------------------------------------------------------- diff --git a/tests/dashboard/test_world_api.py b/tests/dashboard/test_world_api.py index 82cf1f12..582ed15b 100644 --- a/tests/dashboard/test_world_api.py +++ b/tests/dashboard/test_world_api.py @@ -1057,3 +1057,199 @@ lighting: assert lights[0]["color"] == "#FFFFFF" assert lights[0]["intensity"] == 2.0 assert lights[0]["position"] == {"x": 1, "y": 2, "z": 3} + + +# --------------------------------------------------------------------------- +# Matrix Bark Endpoint (/api/matrix/bark) +# --------------------------------------------------------------------------- + + +class TestMatrixBarkEndpoint: + """Tests for the Matrix bark HTTP fallback endpoint.""" + + def setup_method(self): + """Reset rate limiting state before each test.""" + from dashboard.routes.world import _bark_last_request + + _bark_last_request.clear() + + def teardown_method(self): + """Clean up rate limiting state after each test.""" + from dashboard.routes.world import _bark_last_request + + _bark_last_request.clear() + + def test_bark_endpoint_requires_text(self, matrix_client): + """POST /api/matrix/bark returns 422 if text is missing.""" + resp = matrix_client.post("/api/matrix/bark", json={"text": "", "visitor_id": "test123"}) + + assert resp.status_code == 422 + data = resp.json() + assert "error" in data + + def test_bark_endpoint_requires_visitor_id(self, matrix_client): + """POST /api/matrix/bark returns 422 if visitor_id is missing.""" + resp = matrix_client.post("/api/matrix/bark", json={"text": "Hello", "visitor_id": ""}) + + assert resp.status_code == 422 + data = resp.json() + assert "error" in data + + def test_bark_endpoint_returns_bark_format(self, matrix_client): + """POST /api/matrix/bark returns bark in produce_bark format.""" + with patch( + "dashboard.routes.world._generate_bark", + new_callable=AsyncMock, + return_value="Woof! Hello there!", + ): + resp = matrix_client.post( + "/api/matrix/bark", + json={"text": "Hey Timmy!", "visitor_id": "visitor_123"}, + ) + + assert resp.status_code == 200 + data = resp.json() + + # Check produce_bark format + assert data["type"] == "bark" + assert data["agent_id"] == "timmy" + assert "data" in data + assert data["data"]["text"] == "Woof! Hello there!" + assert data["data"]["style"] == "speech" + assert "ts" in data + assert resp.headers["cache-control"] == "no-cache, no-store" + + def test_bark_endpoint_uses_generate_bark(self, matrix_client): + """POST /api/matrix/bark uses _generate_bark for response generation.""" + with patch( + "dashboard.routes.world._generate_bark", + new_callable=AsyncMock, + return_value="Generated response", + ) as mock_generate: + resp = matrix_client.post( + "/api/matrix/bark", + json={"text": "Test message", "visitor_id": "v1"}, + ) + + assert resp.status_code == 200 + mock_generate.assert_called_once_with("Test message") + + def test_bark_endpoint_rate_limit_blocks_second_request(self, matrix_client): + """Second request within 3 seconds returns 429.""" + with patch( + "dashboard.routes.world._generate_bark", + new_callable=AsyncMock, + return_value="Response", + ): + # First request should succeed + resp1 = matrix_client.post( + "/api/matrix/bark", + json={"text": "First", "visitor_id": "rate_test_visitor"}, + ) + assert resp1.status_code == 200 + + # Second request within 3 seconds should be rate limited + resp2 = matrix_client.post( + "/api/matrix/bark", + json={"text": "Second", "visitor_id": "rate_test_visitor"}, + ) + assert resp2.status_code == 429 + data = resp2.json() + assert "error" in data + assert "Rate limit" in data["error"] + + def test_bark_endpoint_rate_limit_per_visitor(self, matrix_client): + """Rate limiting is per-visitor_id, not global.""" + with patch( + "dashboard.routes.world._generate_bark", + new_callable=AsyncMock, + return_value="Response", + ): + # First visitor makes a request + resp1 = matrix_client.post( + "/api/matrix/bark", + json={"text": "Hello", "visitor_id": "visitor_a"}, + ) + assert resp1.status_code == 200 + + # Different visitor can still make a request + resp2 = matrix_client.post( + "/api/matrix/bark", + json={"text": "Hello", "visitor_id": "visitor_b"}, + ) + assert resp2.status_code == 200 + + def test_bark_endpoint_rate_limit_retry_after_header(self, matrix_client): + """429 response includes Retry-After header.""" + with patch( + "dashboard.routes.world._generate_bark", + new_callable=AsyncMock, + return_value="Response", + ): + # First request + matrix_client.post( + "/api/matrix/bark", + json={"text": "First", "visitor_id": "retry_test_visitor"}, + ) + + # Second request (rate limited) + resp = matrix_client.post( + "/api/matrix/bark", + json={"text": "Second", "visitor_id": "retry_test_visitor"}, + ) + + assert resp.status_code == 429 + assert "Retry-After" in resp.headers + # Should be approximately 3 seconds + retry_after = int(resp.headers["Retry-After"]) + assert 1 <= retry_after <= 4 + + def test_bark_endpoint_graceful_fallback_on_error(self, matrix_client): + """When _generate_bark fails, returns graceful fallback.""" + with patch( + "dashboard.routes.world._generate_bark", + new_callable=AsyncMock, + side_effect=RuntimeError("LLM unavailable"), + ): + resp = matrix_client.post( + "/api/matrix/bark", + json={"text": "Hello", "visitor_id": "error_test_visitor"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["type"] == "bark" + assert "tangled" in data["data"]["text"] + + def test_bark_endpoint_strips_whitespace(self, matrix_client): + """Whitespace is stripped from text and visitor_id.""" + with patch( + "dashboard.routes.world._generate_bark", + new_callable=AsyncMock, + return_value="Response", + ) as mock_generate: + resp = matrix_client.post( + "/api/matrix/bark", + json={"text": " Hello Timmy! ", "visitor_id": " visitor_123 "}, + ) + + assert resp.status_code == 200 + # Should be called with stripped text + mock_generate.assert_called_once_with("Hello Timmy!") + + def test_bark_endpoint_response_truncation(self, matrix_client): + """Long responses are truncated to 280 characters.""" + long_response = "A" * 500 + with patch( + "dashboard.routes.world._generate_bark", + new_callable=AsyncMock, + return_value=long_response, + ): + resp = matrix_client.post( + "/api/matrix/bark", + json={"text": "Tell me a long story", "visitor_id": "long_test"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert len(data["data"]["text"]) == 280 -- 2.43.0