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)
92 lines
3 KiB
Rust
92 lines
3 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 <author org>
|
|
//! [`GoogleContact`] — normalised contact returned by the People API.
|
|
|
|
use kei_social_store::people::Person;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// A single contact entry from the Google People API, normalised to flat strings.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct GoogleContact {
|
|
/// E.g. `"people/c123456"` — stable identifier from the People API.
|
|
pub resource_name: String,
|
|
pub display_name: String,
|
|
pub given_name: String,
|
|
pub family_name: String,
|
|
/// All email addresses reported by the API.
|
|
pub emails: Vec<String>,
|
|
/// All phone numbers reported by the API.
|
|
pub phones: Vec<String>,
|
|
/// Primary organization name (first entry).
|
|
pub organization: String,
|
|
/// First biography/note.
|
|
pub bio: String,
|
|
}
|
|
|
|
impl GoogleContact {
|
|
/// Map to a [`kei_social_store::people::Person`] ready for store ingestion.
|
|
///
|
|
/// - `name` — `display_name`, falling back to `"{given} {family}"`.
|
|
/// - `email` — first email or empty string.
|
|
/// - `source` — `"google:{resource_name}"`.
|
|
/// - `id` / `created_at` / `updated_at` — zero (assigned by the store on insert).
|
|
pub fn to_person(&self) -> Person {
|
|
let name = if !self.display_name.is_empty() {
|
|
self.display_name.clone()
|
|
} else {
|
|
format!("{} {}", self.given_name, self.family_name)
|
|
};
|
|
let email = self.emails.first().cloned().unwrap_or_default();
|
|
Person {
|
|
id: 0,
|
|
name,
|
|
email,
|
|
handle: String::new(),
|
|
role: String::new(),
|
|
organization: self.organization.clone(),
|
|
source: format!("google:{}", self.resource_name),
|
|
bio: self.bio.clone(),
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn to_person_maps_correctly() {
|
|
let c = GoogleContact {
|
|
resource_name: "people/c99".to_string(),
|
|
display_name: "Alice Smith".to_string(),
|
|
given_name: "Alice".to_string(),
|
|
family_name: "Smith".to_string(),
|
|
emails: vec!["alice@example.com".to_string()],
|
|
phones: vec!["+1-555-0100".to_string()],
|
|
organization: "ACME Corp".to_string(),
|
|
bio: "Engineer".to_string(),
|
|
};
|
|
let p = c.to_person();
|
|
assert_eq!(p.name, "Alice Smith");
|
|
assert_eq!(p.email, "alice@example.com");
|
|
assert_eq!(p.source, "google:people/c99");
|
|
assert_eq!(p.organization, "ACME Corp");
|
|
assert_eq!(p.bio, "Engineer");
|
|
assert_eq!(p.id, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn to_person_fallback_name() {
|
|
let c = GoogleContact {
|
|
resource_name: "people/c1".to_string(),
|
|
display_name: String::new(),
|
|
given_name: "Bob".to_string(),
|
|
family_name: "Jones".to_string(),
|
|
..Default::default()
|
|
};
|
|
let p = c.to_person();
|
|
assert_eq!(p.name, "Bob Jones");
|
|
}
|
|
}
|