aboutsummaryrefslogtreecommitdiff
path: root/api/chat/post.php
blob: 07cee65e0a04d9c0047a57b4ce2c2376720798f0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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);