[kimi] Add /api/matrix/bark endpoint — HTTP fallback for bark messages (#675) #737

Merged
kimi merged 1 commits from kimi/issue-675 into main 2026-03-21 14:32:05 +00:00
2 changed files with 280 additions and 1 deletions

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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