aboutsummaryrefslogtreecommitdiff
path: root/api/chat/post.php
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-23 19:29:31 +0200
committerAgando <roduccomm@gmail.com>2026-10-23 19:29:31 +0200
commit313f13955ad9c72895b0914ac828c61b3e469715 (patch)
tree8d46bba5452cfe539348a2f1b3fd963b61b45ec4 /api/chat/post.php
parent1ff8df65ffefbd2eaa9100374fbcfb7828fdb717 (diff)
feat(chat): token-scoped chat endpoints, contract, host CLI, old POST retired (v4.6.71 phase B)
- api/chat/{lib,post,poll}.php: thread = sha256(token)[0:16]; flock read-modify-write; tmp+rename; ms epoch cursor; per-thread + per-IP windows with retry_after_ms; 401/413/507/405 carry a machine code, only rate_limited is retryable - _docs/chat-contract.md: frozen shapes, incl. why the cursor is ms and not ts - scripts/chat.py: list/show/reply, UTC, locked, atomic replace - api/debug/agent-replies.php POST -> 410 Gone with pointers (one write path) Measured: 401 no-token, 201+id, poll 1 msg, poll>since 1 new, data/chat 403, 4001 chars 413 (codepoints), 429 with Retry-After 576, round trip via chat.py.
Diffstat (limited to 'api/chat/post.php')
-rw-r--r--api/chat/post.php57
1 files changed, 57 insertions, 0 deletions
diff --git a/api/chat/post.php b/api/chat/post.php
new file mode 100644
index 0000000..07cee65
--- /dev/null
+++ b/api/chat/post.php
@@ -0,0 +1,57 @@
+<?php
+// api/chat/post.php — append one message to the caller's own thread.
+// Contract: _docs/chat-contract.md
+require_once __DIR__ . '/lib.php';
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ chat_error('method not allowed', 405, 'method');
+}
+
+// auth first: a 401 must mean nothing touched disk
+$token = chat_token();
+$thread = chat_thread($token);
+
+$raw = file_get_contents('php://input');
+if (!$raw) error_response('Empty request body');
+if (strlen($raw) > 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);