32 * 1024) chat_error('payload too large (max 32KB)', 413, 'too_large'); $payload = json_decode($raw, true); if (!is_array($payload)) chat_error('invalid JSON', 400, 'bad_json'); $text = trim((string)($payload['text'] ?? '')); if ($text === '') chat_error('text is required', 400, 'text_required'); // Reject rather than truncate: a shortened question earns a wrongly scoped answer. // mb_strlen = codepoints, not bytes: 4000 emoji are 4000 chars and 16 KB. if (mb_strlen($text) > CHAT_MAX_TEXT) { chat_error('text too long (max ' . CHAT_MAX_TEXT . ' chars)', 413, 'too_long', ['limit' => CHAT_MAX_TEXT, 'unit' => 'codepoints', 'actual' => mb_strlen($text)]); } $kind = (string)($payload['kind'] ?? 'operator'); if (!in_array($kind, ['operator', 'agent'], true)) $kind = 'operator'; if (!is_file(chat_path($thread)) && chat_thread_count() >= CHAT_MAX_THREADS) { chat_error('thread limit reached', 507, 'thread_limit', ['limit' => CHAT_MAX_THREADS]); } $rate = chat_rate_check($thread); if (!$rate['ok']) { // The only retryable failure. Everything else (401/413/507) is permanent for this payload. header('Retry-After: ' . (int)ceil($rate['retry_after_ms'] / 1000)); chat_error( 'rate limit: ' . ($rate['scope'] === 'thread' ? CHAT_RATE_MAX . ' per thread' : CHAT_IP_MAX . ' per IP') . ' per 10 min', 429, 'rate_limited', ['scope' => $rate['scope'], 'retry_after_ms' => $rate['retry_after_ms'], 'limit' => $rate['scope'] === 'thread' ? CHAT_RATE_MAX : CHAT_IP_MAX, 'window_s' => CHAT_RATE_WINDOW] ); } $msg = chat_append($thread, $kind, $text); json_response([ 'success' => true, 'id' => $msg['id'], 'ts' => $msg['ts'], 'epoch' => $msg['epoch'], 'thread' => $thread, ], 201);