Parallel agent batch. All five tasks delivered functional + tested.
NOT deployed — user is in live conversation with the bot.
## Crates added (2 new)
### kei-contacts-google (466 LOC, 5 tests)
Thin Google People API client. Takes pre-acquired access_token from
kei-auth-google's OAuth flow; calls /v1/people/me/connections?personFields=...,
parses 200-entry first page (TODO: pagination via nextPageToken), maps
to kei_social_store::Person. Errors: Http / Auth(401) / Parse.
### kei-contacts-apple (593 LOC, 7 tests + 1 doc-test)
CardDAV client for iCloud Contacts using Basic Auth (Apple ID +
app-specific password). Sends REPORT with addressbook-query XML body,
parses multistatus → embedded vCards → AppleContact. Tiny vCard
parser (~150 LOC) handles FN/N/EMAIL/TEL/ORG/NOTE/UID, single-line
only (no line-folding for MVP). Discovery (PROPFIND .well-known/carddav
→ principal → addressbook-home-set) deferred — user supplies
addressbook URL via with_addressbook_url().
Both crates registered in workspace members.
## kei-buddy crate additions
### src/topic_classify.rs (116 LOC, 3 tests)
Free fn classify_and_store_topic(extractor, topics, chat_id, text)
called from process_text when state == OnboardState::Ready. Builds
classifier prompt → LLM → parses {slug, title} → validates slug
shape (kebab-case, ascii) → Topics::add_topic + add_digest. All
failure paths log + return; conversation never blocks.
### src/tick.rs (188 LOC, 3 integration tests) + src/bin/kei-buddy-tick.rs (67 LOC)
Second binary. Oneshot CLI for systemd timer: walks all known
chat_ids in BuddyStore → lists topics → searches recent chat
messages per topic (configurable window/limit) → LLM digest →
Topics::add_digest. Outputs JSON TickReport to stdout. Env-driven
config. NoOpExtractor fallback when no LLM creds (graceful degradation).
### src/commands.rs (146 LOC) + src/command_exec.rs (111 LOC, 7 tests)
Slash-commands intercepted BEFORE handle_step in process_text:
/whois <name> contacts.search_contacts + common_connections for hits
/find <q> chat_log.search scoped to chat_id
/topics topics.list_topics
/contacts contacts.search_contacts("", 10)
/help static usage text (Russian)
If command parsed, response built from stores, sent, logged to
chat_log — FSM skipped for that turn.
### src/serve_runner.rs (69 LOC) — refactor
run_serve + start_listener + init_tracing extracted out of serve.rs
to bring serve.rs back to 189 LOC (was 248 after previous wave).
### Wiring
BuddyContext gains `contacts: Arc<Contacts>` and `topics: Arc<Topics>`.
ServeConfig gains contacts_db_path + topics_db_path. Binary reads
KEI_BUDDY_CONTACTS_DB_PATH + KEI_BUDDY_TOPICS_DB_PATH env (defaults
./kei-buddy-contacts.db, ./kei-buddy-topics.db). cmd_migrate applies
schema for all three side-stores (chat_log + contacts + topics).
## Verify-before-commit (RULE 0.13 §)
* cargo check -p kei-buddy (default + extractor-openai): PASS
* cargo test -p kei-buddy --lib: 41 passed / 0 failed (was 31)
* cargo test -p kei-buddy --tests: 3 passed (tick integration)
* cargo build -p kei-buddy --features extractor-openai: PASS
(builds both kei-buddy + kei-buddy-tick binaries)
* cargo check -p kei-contacts-google: PASS (5 tests)
* cargo check -p kei-contacts-apple: PASS (7 + 1 doc)
* cargo check --workspace: PASS
## STATUS-TRUTH from all 5 agents: shipped=functional, behaviour-verified=yes
## Follow-up (deferred, non-blocking)
* Google People API pagination (nextPageToken loop) — first 200 only
* CardDAV auto-discovery (PROPFIND .well-known/carddav)
* vCard line-folding (RFC 6350 §3.2)
* Wire kei-contacts-google + kei-contacts-apple → Contacts.add_contact
sync command (no glue yet)
* systemd timer file for kei-buddy-tick (not shipped here — config only)
172 lines
6.8 KiB
Rust
172 lines
6.8 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
//! Buddy-specific persistence layer.
|
|
//!
|
|
//! Constructor Pattern: async trait + thin SQLite impl.
|
|
//!
|
|
//! `BuddyStore` is the async trait contract.
|
|
//! `SqliteBuddyStore` wraps a shared `kei_memory_sqlite::SqliteStore`
|
|
//! and implements it via `tokio::task::spawn_blocking` (rusqlite is sync).
|
|
//! Blocking SQL logic lives in `store_ops` (Constructor-pattern split).
|
|
|
|
use async_trait::async_trait;
|
|
use kei_memory_sqlite::SqliteStore;
|
|
use serde_json::Value;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use crate::error::BuddyError;
|
|
use crate::schema::apply_schema_buddy;
|
|
use crate::state::OnboardState;
|
|
use crate::store_ops::{db_load_persona, db_load_state, db_save_persona, db_save_state, now_epoch};
|
|
|
|
// ─── trait ───────────────────────────────────────────────────────────────────
|
|
|
|
/// Async persistence contract for per-chat buddy state.
|
|
#[async_trait]
|
|
pub trait BuddyStore: Send + Sync {
|
|
/// Load the onboarding state for `chat_id`. Returns `None` if no row.
|
|
async fn load_state(&self, chat_id: i64) -> Result<Option<OnboardState>, BuddyError>;
|
|
|
|
/// Persist the onboarding state for `chat_id`.
|
|
async fn save_state(&self, chat_id: i64, state: &OnboardState) -> Result<(), BuddyError>;
|
|
|
|
/// Load the persona blob for `chat_id`. Returns `None` if not set.
|
|
async fn load_persona(&self, chat_id: i64) -> Result<Option<Value>, BuddyError>;
|
|
|
|
/// Persist the persona blob for `chat_id`.
|
|
async fn save_persona(&self, chat_id: i64, persona: &Value) -> Result<(), BuddyError>;
|
|
}
|
|
|
|
// ─── impl ────────────────────────────────────────────────────────────────────
|
|
|
|
/// SQLite-backed `BuddyStore`. Cheap to clone (inner is `Arc`).
|
|
#[derive(Clone)]
|
|
pub struct SqliteBuddyStore {
|
|
inner: Arc<SqliteStore>,
|
|
}
|
|
|
|
impl SqliteBuddyStore {
|
|
/// Wrap an existing `SqliteStore`. Applies the buddy schema.
|
|
pub fn new(store: Arc<SqliteStore>) -> Result<Self, BuddyError> {
|
|
{
|
|
let conn = store.lock();
|
|
apply_schema_buddy(&conn)?;
|
|
}
|
|
Ok(Self { inner: store })
|
|
}
|
|
|
|
/// Open or create a file-backed SQLite DB and apply the buddy schema.
|
|
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, BuddyError> {
|
|
let store = Arc::new(SqliteStore::from_path(path)?);
|
|
Self::new(store)
|
|
}
|
|
|
|
/// Open an in-memory SQLite DB. Useful for tests.
|
|
pub fn from_memory() -> Result<Self, BuddyError> {
|
|
let store = Arc::new(SqliteStore::from_memory()?);
|
|
Self::new(store)
|
|
}
|
|
|
|
/// Lock and return the inner SQLite connection guard.
|
|
///
|
|
/// Used by `tick::load_chat_ids_from_store` to read `buddy_state` chat_ids.
|
|
/// Callers must not hold the guard across `await` points.
|
|
pub fn inner_conn(&self) -> std::sync::MutexGuard<'_, rusqlite::Connection> {
|
|
self.inner.lock()
|
|
}
|
|
}
|
|
|
|
// ─── BuddyStore impl ─────────────────────────────────────────────────────────
|
|
|
|
#[async_trait]
|
|
impl BuddyStore for SqliteBuddyStore {
|
|
async fn load_state(&self, chat_id: i64) -> Result<Option<OnboardState>, BuddyError> {
|
|
let store = Arc::clone(&self.inner);
|
|
tokio::task::spawn_blocking(move || db_load_state(&store.lock(), chat_id))
|
|
.await
|
|
.map_err(|e| BuddyError::Memory(e.to_string()))?
|
|
}
|
|
|
|
async fn save_state(&self, chat_id: i64, state: &OnboardState) -> Result<(), BuddyError> {
|
|
let json =
|
|
serde_json::to_string(state).map_err(|e| BuddyError::Memory(e.to_string()))?;
|
|
let store = Arc::clone(&self.inner);
|
|
let now = now_epoch();
|
|
tokio::task::spawn_blocking(move || db_save_state(&store.lock(), chat_id, &json, now))
|
|
.await
|
|
.map_err(|e| BuddyError::Memory(e.to_string()))?
|
|
}
|
|
|
|
async fn load_persona(&self, chat_id: i64) -> Result<Option<Value>, BuddyError> {
|
|
let store = Arc::clone(&self.inner);
|
|
tokio::task::spawn_blocking(move || db_load_persona(&store.lock(), chat_id))
|
|
.await
|
|
.map_err(|e| BuddyError::Memory(e.to_string()))?
|
|
}
|
|
|
|
async fn save_persona(&self, chat_id: i64, persona: &Value) -> Result<(), BuddyError> {
|
|
let json =
|
|
serde_json::to_string(persona).map_err(|e| BuddyError::Memory(e.to_string()))?;
|
|
let store = Arc::clone(&self.inner);
|
|
let now = now_epoch();
|
|
tokio::task::spawn_blocking(move || db_save_persona(&store.lock(), chat_id, &json, now))
|
|
.await
|
|
.map_err(|e| BuddyError::Memory(e.to_string()))?
|
|
}
|
|
}
|
|
|
|
// ─── tests ───────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn rt() -> tokio::runtime::Runtime {
|
|
tokio::runtime::Runtime::new().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_state_save_and_load() {
|
|
rt().block_on(async {
|
|
let store = SqliteBuddyStore::from_memory().unwrap();
|
|
store.save_state(42, &OnboardState::AskName).await.unwrap();
|
|
let loaded = store.load_state(42).await.unwrap();
|
|
assert_eq!(loaded, Some(OnboardState::AskName));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn load_state_returns_none_for_unknown_chat() {
|
|
rt().block_on(async {
|
|
let store = SqliteBuddyStore::from_memory().unwrap();
|
|
let loaded = store.load_state(999).await.unwrap();
|
|
assert_eq!(loaded, None);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn save_state_updates_existing_row() {
|
|
rt().block_on(async {
|
|
let store = SqliteBuddyStore::from_memory().unwrap();
|
|
store.save_state(1, &OnboardState::AskName).await.unwrap();
|
|
store.save_state(1, &OnboardState::Ready).await.unwrap();
|
|
let loaded = store.load_state(1).await.unwrap();
|
|
assert_eq!(loaded, Some(OnboardState::Ready));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_persona_independent_of_state() {
|
|
rt().block_on(async {
|
|
let store = SqliteBuddyStore::from_memory().unwrap();
|
|
let persona = json!({ "name": "Alice", "tone": "formal" });
|
|
store.save_state(7, &OnboardState::AskTone).await.unwrap();
|
|
store.save_persona(7, &persona).await.unwrap();
|
|
let state = store.load_state(7).await.unwrap();
|
|
let loaded = store.load_persona(7).await.unwrap();
|
|
assert_eq!(state, Some(OnboardState::AskTone));
|
|
assert_eq!(loaded, Some(persona));
|
|
});
|
|
}
|
|
}
|