clock->now(); $expires = $now + max(60, min(30 * 86400, $ttl)); $id = $this->db->insert('chat_sessions', [ 'site_id' => $siteId, 'bot_id' => $botId, 'user_id' => $userId, 'token_hash' => hash('sha256', $token), 'expires_at' => $expires, 'revoked' => 0, 'created_at' => $now, ]); return ['id' => $id, 'token' => $token, 'expires_at' => $expires]; } /** * Resolves a presented token into a principal bound to that session, or * null if the token is unknown, expired, revoked or belongs to another site. * A logged-in user can only use sessions created for them; a guest can only * use sessions with no user. */ public function authenticate(Principal $base, string $token): ?Principal { if ($token === '' || strlen($token) > 128) { return null; } $row = $this->db->selectOne('SELECT id, site_id, user_id, expires_at, revoked FROM {chat_sessions} WHERE token_hash = ?', [hash('sha256', $token)]); if ($row === null || (int) $row['site_id'] !== $base->siteId || (int) $row['revoked'] === 1 || (int) $row['expires_at'] <= $this->clock->now()) { return null; } $owner = $row['user_id'] === null ? null : (int) $row['user_id']; if ($owner !== $base->userId) { return null; } $id = (int) $row['id']; return $base->isAuthenticated() ? $base->withChatSession($id) : Principal::guest($base->siteId, $id); } public function resource(int $siteId, int $sessionId): ?Resource { $row = $this->db->selectOne('SELECT id, site_id, bot_id, user_id, expires_at, revoked FROM {chat_sessions} WHERE id = ? AND site_id = ?', [$sessionId, $siteId]); if ($row === null) { return null; } return new Resource( 'chat_session', (int) $row['id'], (int) $row['site_id'], $row['user_id'] === null ? null : (int) $row['user_id'], (int) $row['id'], ['bot_id' => (int) $row['bot_id'], 'active' => (int) $row['revoked'] === 0 && (int) $row['expires_at'] > $this->clock->now()] ); } public function revoke(int $siteId, int $sessionId): bool { return $this->db->execute('UPDATE {chat_sessions} SET revoked = 1 WHERE id = ? AND site_id = ?', [$sessionId, $siteId]) === 1; } /** Deletes a session and its messages (owner-initiated or retention). */ public function delete(int $siteId, int $sessionId): void { $this->db->transaction(function () use ($siteId, $sessionId): void { // The session row goes FIRST: deleting it takes its row lock, so a concurrent writer // that locks the session before appending a message (ChatService) either finishes // before this point (and its message is deleted below) or finds no session. $this->db->execute('DELETE FROM {chat_sessions} WHERE id = ? AND site_id = ?', [$sessionId, $siteId]); $this->db->execute('DELETE FROM {message_feedback} WHERE site_id = ? AND session_id = ?', [$siteId, $sessionId]); $this->db->execute('DELETE FROM {message_citations} WHERE site_id = ? AND message_id IN (SELECT id FROM {messages} WHERE session_id = ? AND site_id = ?)', [$siteId, $sessionId, $siteId]); $this->db->execute('DELETE FROM {messages} WHERE session_id = ? AND site_id = ?', [$sessionId, $siteId]); $this->db->execute('DELETE FROM {budgets} WHERE site_id = ? AND bucket = ?', [$siteId, 'session:' . $sessionId]); }); } /** * Retention, one bounded step: removes up to $limit sessions (with their messages, citations, feedback and session budget) created more * than $days ago, oldest first, and stops early once $seconds have passed. At least one session is removed per call when any is expired, * so repeated calls always finish. `more` is true when expired sessions may remain: it is false only if the look at the data found no * expired session beyond the ones removed here. Nothing is remembered between calls (the data is the cursor), so a call that never * returns costs nothing but the next call starts where it stopped. Each session goes through {@see delete()}, one transaction each. * * @return array{removed:int, more:bool} */ public function purgeBatch(int $siteId, int $days, int $limit = self::PURGE_BATCH, float $seconds = self::PURGE_SECONDS): array { $limit = max(1, $limit); $cutoff = $this->clock->now() - $days * 86400; $ids = array_map(static fn(array $r): int => (int) $r['id'], $this->db->select('SELECT id FROM {chat_sessions} WHERE site_id = ? AND created_at < ? ORDER BY id LIMIT ?', [$siteId, $cutoff, $limit + 1])); $more = count($ids) > $limit; $deadline = microtime(true) + $seconds; $removed = 0; foreach (array_slice($ids, 0, $limit) as $id) { if ($removed > 0 && microtime(true) >= $deadline) { return ['removed' => $removed, 'more' => true]; } $this->delete($siteId, $id); $removed++; } return ['removed' => $removed, 'more' => $more]; } /** Retention without a time budget: every expired session, in batches (tests, WP-CLI style callers). The daily job uses {@see purgeBatch()}. */ public function purgeOlderThan(int $siteId, int $days): int { $total = 0; do { $step = $this->purgeBatch($siteId, $days, self::PURGE_BATCH, PHP_FLOAT_MAX); $total += $step['removed']; } while ($step['more']); return $total; } }