tim plugin, chat bot sync notifications, test fixes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-14 19:32:17 +02:00
1 parent ca47052768
commit 3b7c7cf5ac
21 files changed
+827 -14

No files matched your search

+6
View File
@@ -189,6 +189,12 @@ $chatColNames = array_column($chatCols, 'name');
if (!in_array('seen', $chatColNames)) {
$db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0');
}
// Bot notifications: automated sync confirmations ("alice synced: My Card").
// `bot` column = 1 means the UI renders it as a system notification in the
// orange accent color instead of a regular chat bubble.
if (!in_array('bot', $chatColNames)) {
$db->exec('ALTER TABLE chat_messages ADD COLUMN bot INTEGER NOT NULL DEFAULT 0');
}
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)');
// Collaborative buckets: shared among multiple tenants. Each bucket has one
+25 -1
View File
@@ -62,7 +62,7 @@ if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404);
$peerId = $peerTenant['id'];
$stmt = $db->prepare('
SELECT id, sender_id, sender_username, body, created_at, seen
SELECT id, sender_id, sender_username, body, created_at, seen, bot
FROM chat_messages
WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)
ORDER BY created_at ASC LIMIT 500
@@ -78,6 +78,7 @@ if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
'body' => clientDecrypt($row['body'] ?? '', $interKey),
'created_at' => $row['created_at'],
'seen' => (int)$row['seen'],
'bot' => (int)($row['bot'] ?? 0),
];
}
// Mark incoming as seen
@@ -106,6 +107,29 @@ if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) {
$clientTenant = $clientTenant ?? authClient($db);
// Bot notification: client announces "I synced a card" to the sender.
// No encryption — body is a short system-generated string like
// "alice synced: Card Title". bot=1 flags it so the UI renders in
// the orange accent color.
if ($route === '/api/chat/bot' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$body = trim((string)($input['body'] ?? ''));
$recipientName = trim((string)($input['recipient'] ?? ''));
if ($body === '') jsonResponse(['error' => 'body required'], 400);
if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400);
if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // no self-notifications
$recipient = getTenantByUsername($db, $recipientName);
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$stored = clientEncrypt($body, $interKey);
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, bot) VALUES (:r, :s, :sn, :b, 1)')
->execute(['r' => $recipient['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'b' => $stored]);
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]);
}
if ($route === '/api/chat' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
+11 -3
View File
@@ -719,12 +719,20 @@ async function loadChatHistory(peer, quiet = false) {
return;
}
container.innerHTML = messages.map(m => `
container.innerHTML = messages.map(m => {
if (m.bot) {
return `
<div class="chat-msg bot">
<div><i class="ph ph-robot"></i> ${escapeHtml(m.body)}</div>
<div class="msg-time">${formatChatTime(m.created_at)}</div>
</div>`;
}
return `
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
<div>${escapeHtml(m.body)}</div>
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
</div>
`).join('');
</div>`;
}).join('');
if (wasAtBottom || !quiet) {
container.scrollTop = container.scrollHeight;
+7
View File
@@ -280,6 +280,13 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
}
.chat-msg.mine { align-self: flex-end; background: var(--accent); color: var(--bg); border-bottom-right-radius: 2px; }
.chat-msg.theirs { align-self: flex-start; background: var(--raised); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 2px; }
.chat-msg.bot {
align-self: center; max-width: 90%;
background: rgba(251, 146, 60, 0.12); color: #fb923c;
border: 1px solid rgba(251, 146, 60, 0.35);
font-size: 11px; text-align: center;
}
.chat-msg.bot .msg-time { text-align: center; }
.chat-msg .msg-time { font-family: var(--pixel); font-size: 8px; opacity: 0.6; margin-top: 4px; }
.chat-msg.mine .msg-time { text-align: right; }
.chat-input-bar { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }