[kimi] Add /api/matrix/bark endpoint — HTTP fallback for bark messages (#675) #737
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user