KeiSeiKit-1.0/_primitives/_rust/kei-contacts-apple/src/contact.rs
Parfii-bot 3f2aa1189b feat(kei-buddy fleet): 5 atomics — google/apple contacts + classifier + tick + slash-commands
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)
2026-05-12 16:33:58 +08:00

97 lines
3.1 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! [`AppleContact`] — normalised contact returned by the CardDAV client.
use kei_social_store::people::Person;
use serde::{Deserialize, Serialize};
/// A single contact entry from iCloud CardDAV, normalised to flat strings.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AppleContact {
/// vCard UID property (stable identifier).
pub uid: String,
/// Formatted name (FN property).
pub display_name: String,
/// Given name (first component of N property).
pub given_name: String,
/// Family name (second component of N property).
pub family_name: String,
/// All EMAIL values from the vCard.
pub emails: Vec<String>,
/// All TEL values from the vCard.
pub phones: Vec<String>,
/// ORG property (first component).
pub organization: String,
/// NOTE property.
pub note: String,
/// Original vCard text (verbatim).
pub raw_vcard: String,
}
impl AppleContact {
/// Map to a [`kei_social_store::people::Person`] for store ingestion.
///
/// - `name` — `display_name`, falling back to `"{given} {family}"`.
/// - `email` — first email or empty string.
/// - `source` — `"apple:{uid}"`.
/// - `id` / `created_at` / `updated_at` — zero (assigned by the store).
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!("apple:{}", self.uid),
bio: self.note.clone(),
created_at: 0,
updated_at: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_person_maps_correctly() {
let c = AppleContact {
uid: "abc-123".to_string(),
display_name: "Denis Parfionovich".to_string(),
given_name: "Denis".to_string(),
family_name: "Parfionovich".to_string(),
emails: vec!["denis@example.com".to_string()],
phones: vec!["+1234567890".to_string()],
organization: "KeiSei Labs".to_string(),
note: "founder".to_string(),
raw_vcard: String::new(),
};
let p = c.to_person();
assert_eq!(p.name, "Denis Parfionovich");
assert_eq!(p.email, "denis@example.com");
assert_eq!(p.source, "apple:abc-123");
assert_eq!(p.organization, "KeiSei Labs");
assert_eq!(p.bio, "founder");
assert_eq!(p.id, 0);
}
#[test]
fn to_person_fallback_name() {
let c = AppleContact {
uid: "x".to_string(),
given_name: "Alice".to_string(),
family_name: "Smith".to_string(),
..Default::default()
};
let p = c.to_person();
assert_eq!(p.name, "Alice Smith");
}
}