chat: client-side rooms, disposable flag, history backfill
Extends the local chat cache to mirror rooms + membership from the
relay. chat_messages grows a room_uid column; peer is reused as
author-username for group rows so a single table covers both kinds.
New local tables:
chat_rooms — uid, name, owner, synced_at
chat_room_members — room_uid + username
chat_thread_settings — per-peer disposable flag
New / extended endpoints:
GET/POST/PATCH/DELETE /api/chat/rooms[/{uid}] — proxy to relay
POST /api/chat/backfill — pulls older messages from relay
GET/POST /api/chat/thread-settings — disposable toggle
/api/chat/conversations — mixed list of peers + rooms
/api/chat/messages — accepts peer OR room_uid
/api/chat/send — accepts peer OR room_uid; forwards disposable
/api/chat/poll — mirrors rooms on every tick; sets direction
from `mine` flag so multi-device group sends
render as out-bubbles
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
044caae447
commit
52da9abd44
1 file changed
+371
-19
+371
-19
@@ -43,6 +43,46 @@
|
||||
if (!in_array('seen', $colNames)) {
|
||||
$db->exec("ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
// Group chat: `room_uid` set → message belongs to a room (group
|
||||
// thread), and `peer` holds the author's username (always — 1:1
|
||||
// messages use peer for the other party, group messages reuse the
|
||||
// column for the sender). NULL room_uid = classic 1:1.
|
||||
if (!in_array('room_uid', $colNames)) {
|
||||
$db->exec("ALTER TABLE chat_messages ADD COLUMN room_uid TEXT");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_chat_room ON chat_messages(room_uid, id)");
|
||||
}
|
||||
|
||||
// Chat rooms + membership, mirrored from the relay on poll. The
|
||||
// client is the read-through cache; relay remains the source of
|
||||
// truth so a second device can join a room just by polling.
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS chat_rooms (
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner_username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
");
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS chat_room_members (
|
||||
room_uid TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
PRIMARY KEY (room_uid, username)
|
||||
)
|
||||
");
|
||||
|
||||
// Per-thread settings. Currently: `disposable` flag for 1:1 threads
|
||||
// — when true, the relay drops the messages after the 7-day window
|
||||
// instead of keeping them in the long-term store. Rooms are always
|
||||
// persistent (enforced relay-side), so this only applies to peers.
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS chat_thread_settings (
|
||||
peer TEXT PRIMARY KEY,
|
||||
disposable INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
");
|
||||
|
||||
// Track the largest relay msg id we've ever seen (so the next poll uses since=that)
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS chat_state (
|
||||
@@ -106,49 +146,102 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
|
||||
if ($route === '/api/chat/conversations' && $method === 'GET') {
|
||||
$db = Database::connect();
|
||||
// Latest message per peer + unread count, single query
|
||||
$rows = $db->query("
|
||||
// 1:1 threads — one row per peer (room_uid IS NULL).
|
||||
$oneOnOne = $db->query("
|
||||
SELECT
|
||||
peer,
|
||||
'peer' AS kind,
|
||||
peer AS key,
|
||||
peer AS label,
|
||||
MAX(sent_at) AS last_at,
|
||||
(SELECT body FROM chat_messages c2 WHERE c2.peer = c1.peer ORDER BY id DESC LIMIT 1) AS last_body,
|
||||
(SELECT direction FROM chat_messages c3 WHERE c3.peer = c1.peer ORDER BY id DESC LIMIT 1) AS last_direction,
|
||||
(SELECT body FROM chat_messages c2 WHERE c2.peer = c1.peer AND c2.room_uid IS NULL ORDER BY id DESC LIMIT 1) AS last_body,
|
||||
(SELECT direction FROM chat_messages c3 WHERE c3.peer = c1.peer AND c3.room_uid IS NULL ORDER BY id DESC LIMIT 1) AS last_direction,
|
||||
SUM(CASE WHEN direction = 'in' AND is_read = 0 THEN 1 ELSE 0 END) AS unread
|
||||
FROM chat_messages c1
|
||||
WHERE room_uid IS NULL
|
||||
GROUP BY peer
|
||||
ORDER BY last_at DESC
|
||||
")->fetchAll();
|
||||
$totalUnread = array_sum(array_map(fn($r) => (int)$r['unread'], $rows));
|
||||
|
||||
// Group rooms — join rooms to their newest message (if any); rooms
|
||||
// with no messages yet still render so the user can post there.
|
||||
$rooms = $db->query("
|
||||
SELECT
|
||||
'room' AS kind,
|
||||
r.uid AS key,
|
||||
r.name AS label,
|
||||
r.owner_username AS owner,
|
||||
(SELECT MAX(sent_at) FROM chat_messages WHERE room_uid = r.uid) AS last_at,
|
||||
(SELECT body FROM chat_messages WHERE room_uid = r.uid ORDER BY id DESC LIMIT 1) AS last_body,
|
||||
(SELECT peer FROM chat_messages WHERE room_uid = r.uid ORDER BY id DESC LIMIT 1) AS last_author,
|
||||
(SELECT COUNT(*) FROM chat_messages WHERE room_uid = r.uid AND direction = 'in' AND is_read = 0) AS unread
|
||||
FROM chat_rooms r
|
||||
")->fetchAll();
|
||||
|
||||
$rows = array_merge($oneOnOne, $rooms);
|
||||
// Sort newest-first, nulls (empty rooms) to the bottom.
|
||||
usort($rows, function ($a, $b) {
|
||||
$al = $a['last_at'] ?? ''; $bl = $b['last_at'] ?? '';
|
||||
if ($al === $bl) return 0;
|
||||
if ($al === '') return 1;
|
||||
if ($bl === '') return -1;
|
||||
return strcmp($bl, $al);
|
||||
});
|
||||
$totalUnread = array_sum(array_map(fn($r) => (int)($r['unread'] ?? 0), $rows));
|
||||
chatJson(['conversations' => $rows, 'total_unread' => $totalUnread]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/messages' && $method === 'GET') {
|
||||
$peer = trim((string)($_GET['peer'] ?? ''));
|
||||
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||
$roomUid = trim((string)($_GET['room_uid'] ?? ''));
|
||||
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
|
||||
$db = Database::connect();
|
||||
$stmt = $db->prepare('SELECT id, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p ORDER BY id ASC LIMIT 500');
|
||||
if ($roomUid !== '') {
|
||||
// Group thread: return all messages for the room. `peer` is the
|
||||
// author username; UI uses author === self to decide bubble
|
||||
// alignment instead of the direction flag (direction is set
|
||||
// from THIS device's perspective, so a message sent from
|
||||
// another device comes in as direction='in' while peer==self).
|
||||
$stmt = $db->prepare('SELECT id, peer, direction, body, sent_at, is_read, delivered, seen, room_uid FROM chat_messages WHERE room_uid = :r ORDER BY id ASC LIMIT 500');
|
||||
$stmt->execute(['r' => $roomUid]);
|
||||
} else {
|
||||
$stmt = $db->prepare('SELECT id, peer, direction, body, sent_at, is_read, delivered, seen FROM chat_messages WHERE peer = :p AND room_uid IS NULL ORDER BY id ASC LIMIT 500');
|
||||
$stmt->execute(['p' => $peer]);
|
||||
}
|
||||
chatJson(['messages' => $stmt->fetchAll()]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/send' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$peer = trim((string)($input['peer'] ?? ''));
|
||||
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||
$body = (string)($input['body'] ?? '');
|
||||
if ($peer === '' || $body === '') chatJson(['error' => 'peer and body required'], 400);
|
||||
if ($body === '') chatJson(['error' => 'body required'], 400);
|
||||
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
|
||||
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||
chatJson(['error' => 'relay not configured (set URL, username, and secret in settings)'], 400);
|
||||
}
|
||||
|
||||
$db = Database::connect();
|
||||
// Look up per-thread disposable flag (1:1 only; rooms are always
|
||||
// persistent on the relay).
|
||||
$disposable = 0;
|
||||
if ($peer !== '') {
|
||||
$stmt = $db->prepare('SELECT disposable FROM chat_thread_settings WHERE peer = :p');
|
||||
$stmt->execute(['p' => $peer]);
|
||||
$disposable = (int)($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
$envelope = chatEncrypt($body, $r['key']);
|
||||
$payload = $roomUid !== ''
|
||||
? ['room_uid' => $roomUid, 'body' => $envelope]
|
||||
: ['recipient' => $peer, 'body' => $envelope, 'disposable' => $disposable];
|
||||
$relayChatUrl = rtrim($r['url'], '/') . '/api/chat';
|
||||
$ch = curl_init($relayChatUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['recipient' => $peer, 'body' => $envelope]),
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'X-Relay-User: ' . $r['user'],
|
||||
@@ -176,11 +269,16 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
$relayId = isset($resData['id']) ? (int)$resData['id'] : null;
|
||||
|
||||
// Store the outgoing message locally so the UI can render it
|
||||
// immediately. relay_id is the relay's autoincrement id; the
|
||||
// receipt-poll matches against it to update delivered/seen state.
|
||||
$db = Database::connect();
|
||||
$db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0)')
|
||||
->execute(['p' => $peer, 'b' => $body, 'rid' => $relayId]);
|
||||
// immediately. For group messages `peer` holds the author
|
||||
// (= self); for 1:1 it holds the other party as before.
|
||||
$localPeer = $roomUid !== '' ? $r['user'] : $peer;
|
||||
$db->prepare('INSERT INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read, delivered, seen, room_uid) VALUES (:p, "out", :b, :rid, datetime("now"), 1, 0, 0, :ru)')
|
||||
->execute([
|
||||
'p' => $localPeer,
|
||||
'b' => $body,
|
||||
'rid' => $relayId,
|
||||
'ru' => $roomUid !== '' ? $roomUid : null,
|
||||
]);
|
||||
chatJson(['ok' => true, 'id' => (int)$db->lastInsertId()]);
|
||||
}
|
||||
|
||||
@@ -217,20 +315,78 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
|
||||
$db = Database::connect();
|
||||
$newCount = 0;
|
||||
$insert = $db->prepare('INSERT OR IGNORE INTO chat_messages (peer, direction, body, relay_id, sent_at, is_read) VALUES (:p, "in", :b, :rid, :ts, 0)');
|
||||
// `mine` on group messages (== came back from the relay because
|
||||
// another device of ours sent it) gets direction='out' so the UI
|
||||
// renders it aligned right; relay_id uniqueness dedupes against
|
||||
// the local insert we did on send.
|
||||
$insert = $db->prepare('
|
||||
INSERT OR IGNORE INTO chat_messages
|
||||
(peer, direction, body, relay_id, sent_at, is_read, room_uid)
|
||||
VALUES (:p, :dir, :b, :rid, :ts, 0, :ru)
|
||||
');
|
||||
foreach ($messages as $m) {
|
||||
$plain = chatDecrypt($m['body'] ?? '', $r['key']);
|
||||
if ($plain === '') continue;
|
||||
$isGroup = !empty($m['room_uid']);
|
||||
$mine = !empty($m['mine']);
|
||||
$insert->execute([
|
||||
'p' => $m['sender_username'] ?? '?',
|
||||
'dir' => $mine ? 'out' : 'in',
|
||||
'b' => $plain,
|
||||
'rid' => (int)$m['id'],
|
||||
'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
'ru' => $isGroup ? $m['room_uid'] : null,
|
||||
]);
|
||||
if ($insert->rowCount() > 0) $newCount++;
|
||||
}
|
||||
if ($maxId > $since) chatSetState('last_relay_id', $maxId);
|
||||
|
||||
// Mirror rooms + membership into the local cache on every poll.
|
||||
// Cheap (one extra GET), and it lets a second device see a room
|
||||
// it was just added to without a separate fetch.
|
||||
$ch3 = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms');
|
||||
curl_setopt_array($ch3, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$resp3 = curl_exec($ch3);
|
||||
$st3 = curl_getinfo($ch3, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch3);
|
||||
if ($st3 === 200) {
|
||||
$roomsData = json_decode($resp3, true) ?: [];
|
||||
$rooms = $roomsData['rooms'] ?? [];
|
||||
$remoteUids = [];
|
||||
$upsert = $db->prepare('INSERT OR REPLACE INTO chat_rooms (uid, name, owner_username, created_at, synced_at) VALUES (:u, :n, :o, :c, datetime("now"))');
|
||||
$delMem = $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u');
|
||||
$insMem = $db->prepare('INSERT OR IGNORE INTO chat_room_members (room_uid, username) VALUES (:u, :un)');
|
||||
foreach ($rooms as $room) {
|
||||
$uid = (string)($room['uid'] ?? '');
|
||||
if ($uid === '') continue;
|
||||
$remoteUids[] = $uid;
|
||||
$upsert->execute([
|
||||
'u' => $uid,
|
||||
'n' => (string)($room['name'] ?? $uid),
|
||||
'o' => (string)($room['owner_username'] ?? ''),
|
||||
'c' => (string)($room['created_at'] ?? date('Y-m-d H:i:s')),
|
||||
]);
|
||||
$delMem->execute(['u' => $uid]);
|
||||
foreach (($room['members'] ?? []) as $member) {
|
||||
$insMem->execute(['u' => $uid, 'un' => (string)$member]);
|
||||
}
|
||||
}
|
||||
// Drop local rooms we were removed from (owner kick / self-leave
|
||||
// on another device). Messages for them stay on disk — harmless,
|
||||
// UI won't list them without a room entry.
|
||||
$existing = $db->query('SELECT uid FROM chat_rooms')->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($existing as $uid) {
|
||||
if (!in_array($uid, $remoteUids, true)) {
|
||||
$db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]);
|
||||
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receipt poll for our SENT messages ──────────────────
|
||||
// Pulls fetched/seen state for everything we've sent. Updates
|
||||
// local outgoing rows by relay_id. Uses an independent `since`
|
||||
@@ -288,9 +444,18 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
if ($route === '/api/chat/read' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$peer = trim((string)($input['peer'] ?? ''));
|
||||
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
|
||||
$db = Database::connect();
|
||||
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND direction = "in" AND is_read = 0')
|
||||
if ($roomUid !== '') {
|
||||
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE room_uid = :r AND direction = "in" AND is_read = 0')
|
||||
->execute(['r' => $roomUid]);
|
||||
// Group "seen" receipts aren't a thing in v1 — skip the relay
|
||||
// notification. The local is_read flag is enough for badge UI.
|
||||
chatJson(['ok' => true]);
|
||||
}
|
||||
|
||||
$db->prepare('UPDATE chat_messages SET is_read = 1 WHERE peer = :p AND room_uid IS NULL AND direction = "in" AND is_read = 0')
|
||||
->execute(['p' => $peer]);
|
||||
|
||||
// Tell the relay this peer's messages have been seen by us so the
|
||||
@@ -338,4 +503,191 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
$data = json_decode($response, true) ?: [];
|
||||
chatJson(['ok' => true, 'tenants' => $data['tenants'] ?? [], 'self' => $data['self'] ?? $r['user']]);
|
||||
}
|
||||
|
||||
// ── Rooms CRUD (proxy to relay) ──────────────────────────────
|
||||
|
||||
if ($route === '/api/chat/rooms' && $method === 'GET') {
|
||||
$db = Database::connect();
|
||||
$rooms = $db->query('SELECT uid, name, owner_username, created_at FROM chat_rooms ORDER BY created_at DESC')->fetchAll();
|
||||
$mstmt = $db->prepare('SELECT username FROM chat_room_members WHERE room_uid = :u ORDER BY username');
|
||||
foreach ($rooms as &$room) {
|
||||
$mstmt->execute(['u' => $room['uid']]);
|
||||
$room['members'] = array_column($mstmt->fetchAll(), 'username');
|
||||
}
|
||||
chatJson(['ok' => true, 'rooms' => $rooms]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/rooms' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$name = trim((string)($input['name'] ?? ''));
|
||||
$members = is_array($input['members'] ?? null) ? $input['members'] : [];
|
||||
if ($name === '') chatJson(['error' => 'name required'], 400);
|
||||
if (count(array_filter($members, fn($m) => trim((string)$m) !== '')) === 0) {
|
||||
chatJson(['error' => 'at least one other member required'], 400);
|
||||
}
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
|
||||
|
||||
// Client-generated UID, same convention as collab buckets.
|
||||
$uid = bin2hex(random_bytes(8));
|
||||
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['uid' => $uid, 'name' => $name, 'members' => $members]),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'X-Relay-User: ' . $r['user'],
|
||||
'X-Relay-Key: ' . $r['key'],
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($status !== 200) {
|
||||
$err = json_decode($response, true);
|
||||
chatJson(['ok' => false, 'error' => $err['error'] ?? "relay HTTP $status"]);
|
||||
}
|
||||
$data = json_decode($response, true) ?: [];
|
||||
// Mirror locally right away so the next conversations list shows
|
||||
// the room without waiting for a poll.
|
||||
$db = Database::connect();
|
||||
$db->prepare('INSERT OR REPLACE INTO chat_rooms (uid, name, owner_username, created_at, synced_at) VALUES (:u, :n, :o, datetime("now"), datetime("now"))')
|
||||
->execute(['u' => $uid, 'n' => $name, 'o' => $r['user']]);
|
||||
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||
$insMem = $db->prepare('INSERT INTO chat_room_members (room_uid, username) VALUES (:u, :un)');
|
||||
foreach (($data['members'] ?? []) as $mu) {
|
||||
$insMem->execute(['u' => $uid, 'un' => $mu]);
|
||||
}
|
||||
chatJson(['ok' => true, 'uid' => $uid, 'members' => $data['members'] ?? []]);
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
|
||||
$uid = $m[1];
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
|
||||
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms/' . urlencode($uid));
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => 'PATCH',
|
||||
CURLOPT_POSTFIELDS => json_encode($input),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'X-Relay-User: ' . $r['user'],
|
||||
'X-Relay-Key: ' . $r['key'],
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
// The next poll will refresh the local room mirror with the new
|
||||
// name / member list. No need to replay the update here.
|
||||
chatJson(json_decode($response, true) ?: ['ok' => $status === 200]);
|
||||
}
|
||||
|
||||
if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
|
||||
$uid = $m[1];
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
|
||||
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms/' . urlencode($uid));
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($status === 200) {
|
||||
$db = Database::connect();
|
||||
$db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]);
|
||||
$db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]);
|
||||
}
|
||||
chatJson(json_decode($response, true) ?: ['ok' => $status === 200]);
|
||||
}
|
||||
|
||||
// ── Backfill older messages for a thread (scrollback / cold start) ──
|
||||
|
||||
if ($route === '/api/chat/backfill' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$peer = trim((string)($input['peer'] ?? ''));
|
||||
$roomUid = trim((string)($input['room_uid'] ?? ''));
|
||||
$before = (int)($input['before'] ?? 0);
|
||||
$limit = max(1, min(500, (int)($input['limit'] ?? 100)));
|
||||
if ($peer === '' && $roomUid === '') chatJson(['error' => 'peer or room_uid required'], 400);
|
||||
|
||||
$r = chatRelaySettings();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) chatJson(['error' => 'relay not configured'], 400);
|
||||
|
||||
$qs = http_build_query(array_filter([
|
||||
'room_uid' => $roomUid ?: null,
|
||||
'peer' => $peer ?: null,
|
||||
'before' => $before > 0 ? $before : null,
|
||||
'limit' => $limit,
|
||||
], fn($v) => $v !== null));
|
||||
$ch = curl_init(rtrim($r['url'], '/') . '/api/chat/backfill?' . $qs);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $r['user'], 'X-Relay-Key: ' . $r['key']],
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($status !== 200) {
|
||||
$err = json_decode($response, true);
|
||||
chatJson(['ok' => false, 'error' => $err['error'] ?? "relay HTTP $status"]);
|
||||
}
|
||||
$data = json_decode($response, true) ?: [];
|
||||
$messages = $data['messages'] ?? [];
|
||||
|
||||
$db = Database::connect();
|
||||
$newCount = 0;
|
||||
$insert = $db->prepare('
|
||||
INSERT OR IGNORE INTO chat_messages
|
||||
(peer, direction, body, relay_id, sent_at, is_read, room_uid)
|
||||
VALUES (:p, :dir, :b, :rid, :ts, 1, :ru)
|
||||
');
|
||||
foreach ($messages as $m) {
|
||||
$plain = chatDecrypt($m['body'] ?? '', $r['key']);
|
||||
if ($plain === '') continue;
|
||||
$insert->execute([
|
||||
'p' => $m['sender_username'] ?? '?',
|
||||
'dir' => !empty($m['mine']) ? 'out' : 'in',
|
||||
'b' => $plain,
|
||||
'rid' => (int)$m['id'],
|
||||
'ts' => $m['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
'ru' => !empty($m['room_uid']) ? $m['room_uid'] : null,
|
||||
]);
|
||||
if ($insert->rowCount() > 0) $newCount++;
|
||||
}
|
||||
chatJson(['ok' => true, 'backfilled' => $newCount]);
|
||||
}
|
||||
|
||||
// ── Per-thread settings (currently just the disposable flag) ──
|
||||
|
||||
if ($route === '/api/chat/thread-settings' && $method === 'GET') {
|
||||
$peer = trim((string)($_GET['peer'] ?? ''));
|
||||
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||
$db = Database::connect();
|
||||
$stmt = $db->prepare('SELECT disposable FROM chat_thread_settings WHERE peer = :p');
|
||||
$stmt->execute(['p' => $peer]);
|
||||
$disposable = (int)($stmt->fetchColumn() ?: 0);
|
||||
chatJson(['ok' => true, 'peer' => $peer, 'disposable' => $disposable]);
|
||||
}
|
||||
|
||||
if ($route === '/api/chat/thread-settings' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$peer = trim((string)($input['peer'] ?? ''));
|
||||
$disposable = !empty($input['disposable']) ? 1 : 0;
|
||||
if ($peer === '') chatJson(['error' => 'peer required'], 400);
|
||||
$db = Database::connect();
|
||||
$db->prepare('INSERT OR REPLACE INTO chat_thread_settings (peer, disposable) VALUES (:p, :d)')
|
||||
->execute(['p' => $peer, 'd' => $disposable]);
|
||||
chatJson(['ok' => true, 'peer' => $peer, 'disposable' => $disposable]);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user