feat: add /api/matrix/bark endpoint — HTTP fallback for bark messages
Some checks failed
Tests / lint (pull_request) Has been cancelled
Tests / test (pull_request) Has been cancelled

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
This commit is contained in:
kimi
2026-03-21 10:30:02 -04:00
parent 9d4ac8e7cc
commit f92da1d20c
2 changed files with 280 additions and 1 deletions

View File

@@ -28,9 +28,10 @@ from typing import Any
import yaml import yaml
from fastapi import APIRouter, WebSocket from fastapi import APIRouter, WebSocket
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel
from config import settings 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 from timmy.workshop_state import PRESENCE_FILE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,6 +39,88 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/world", tags=["world"]) router = APIRouter(prefix="/api/world", tags=["world"])
matrix_router = APIRouter(prefix="/api/matrix", tags=["matrix"]) 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 # Matrix Agent Registry — serves agents to the Matrix visualization
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1057,3 +1057,199 @@ lighting:
assert lights[0]["color"] == "#FFFFFF" assert lights[0]["color"] == "#FFFFFF"
assert lights[0]["intensity"] == 2.0 assert lights[0]["intensity"] == 2.0
assert lights[0]["position"] == {"x": 1, "y": 2, "z": 3} 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