diff --git a/public/plugins/chat/backend.php b/public/plugins/chat/backend.php index 49ced16..9965465 100644 --- a/public/plugins/chat/backend.php +++ b/public/plugins/chat/backend.php @@ -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'); - $stmt->execute(['p' => $peer]); + 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'] ?? '?', - 'b' => $plain, + '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'), + '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]); + } });