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)
This commit is contained in:
Parfii-bot 2026-05-12 16:33:58 +08:00
parent ff74c5554e
commit 3f2aa1189b
31 changed files with 2036 additions and 91 deletions

View file

@ -3192,8 +3192,12 @@ dependencies = [
"anyhow",
"async-trait",
"axum",
"chrono",
"clap",
"kei-chat-store",
"kei-memory-sqlite",
"kei-sage",
"kei-social-store",
"kei-telegram-webhook",
"reqwest 0.12.28",
"rusqlite",
@ -3336,6 +3340,34 @@ dependencies = [
"walkdir",
]
[[package]]
name = "kei-contacts-apple"
version = "0.1.0"
dependencies = [
"kei-social-store",
"regex",
"reqwest 0.12.28",
"serde",
"thiserror 1.0.69",
"tokio",
"tracing",
"wiremock",
]
[[package]]
name = "kei-contacts-google"
version = "0.1.0"
dependencies = [
"kei-social-store",
"reqwest 0.12.28",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tracing",
"wiremock",
]
[[package]]
name = "kei-content-store"
version = "0.1.0"

View file

@ -187,6 +187,10 @@ members = [
"kei-tts",
# STT abstraction — 3 backends (whisper-local/Deepgram/OpenAI-Whisper) behind feature flags
"kei-stt",
# Wave N — Google People API thin client (token-in, Vec<GoogleContact>-out)
"kei-contacts-google",
# Wave N+1 — iCloud CardDAV thin client (Basic Auth, Vec<AppleContact>-out)
"kei-contacts-apple",
]
[workspace.package]

View file

@ -11,6 +11,10 @@ license.workspace = true
name = "kei-buddy"
path = "src/bin/kei-buddy.rs"
[[bin]]
name = "kei-buddy-tick"
path = "src/bin/kei-buddy-tick.rs"
[lib]
name = "kei_buddy"
path = "src/lib.rs"

View file

@ -0,0 +1,67 @@
// SPDX-License-Identifier: Apache-2.0
//! `kei-buddy-tick` — oneshot digest-generation binary.
//!
//! Meant for systemd timer / cron. Reads env, calls `run_tick`,
//! prints JSON report to stdout, exits 0.
use kei_buddy::{run_tick, TickConfig};
#[tokio::main]
async fn main() {
init_log();
let cfg = cfg_from_env();
match run_tick(cfg).await {
Ok(report) => {
let out = serde_json::json!({
"topics_processed": report.topics_processed,
"digests_created": report.digests_created,
"errors": report.errors,
});
println!("{}", out);
}
Err(e) => {
let out = serde_json::json!({
"fatal": e.to_string(),
"topics_processed": 0,
"digests_created": 0,
"errors": [e.to_string()],
});
// Print to stdout so callers can parse; fatal still exits 0.
println!("{}", out);
}
}
}
fn cfg_from_env() -> TickConfig {
TickConfig {
buddy_db_path: env_or("KEI_BUDDY_DB_PATH", "./kei-buddy.db"),
chat_log_db_path: env_or("KEI_BUDDY_CHAT_LOG_PATH", "./kei-buddy-chat.db"),
topics_db_path: env_or("KEI_BUDDY_TOPICS_DB_PATH", "./kei-buddy-topics.db"),
since_hours: env_i64("KEI_BUDDY_TICK_SINCE_HOURS", 24),
max_messages_per_topic: env_i64("KEI_BUDDY_TICK_MAX_MESSAGES", 50),
llm_proxy_url: std::env::var("KEI_BUDDY_LLM_PROXY").ok(),
llm_api_key: std::env::var("KEI_BUDDY_LLM_KEY")
.ok()
.or_else(|| std::env::var("OPENAI_API_KEY").ok()),
llm_model: std::env::var("KEI_BUDDY_LLM_MODEL").ok(),
}
}
fn env_or(name: &str, default: &str) -> String {
std::env::var(name).unwrap_or_else(|_| default.to_string())
}
fn env_i64(name: &str, default: i64) -> i64 {
std::env::var(name)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn init_log() {
#[cfg(feature = "serve")]
{
use tracing_subscriber::{fmt, EnvFilter};
let _ = fmt().with_env_filter(EnvFilter::from_default_env()).try_init();
}
}

View file

@ -57,6 +57,8 @@ async fn cmd_serve() -> anyhow::Result<()> {
.or_else(|| std::env::var("OPENAI_API_KEY").ok()),
llm_model: std::env::var("KEI_BUDDY_LLM_MODEL").ok(),
chat_log_db_path: chat_log_path_from_env(),
topics_db_path: topics_db_path_from_env(),
contacts_db_path: contacts_db_path_from_env(),
};
run_serve(cfg).await
}
@ -87,8 +89,18 @@ fn cmd_migrate() -> anyhow::Result<()> {
let _store = kei_buddy::store::SqliteBuddyStore::from_path(&path)?;
let chat_log_path = chat_log_path_from_env();
let _ = kei_buddy::ChatLog::from_path(&chat_log_path)?;
let topics_path = topics_db_path_from_env();
let _ = kei_buddy::Topics::from_path(&topics_path)?;
let contacts_path = contacts_db_path_from_env();
let _ = kei_buddy::Contacts::from_path(&contacts_path)?;
init_log();
tracing::info!(path = %path, chat_log_path = %chat_log_path, "schema applied");
tracing::info!(
path = %path,
chat_log_path = %chat_log_path,
topics_path = %topics_path,
contacts_path = %contacts_path,
"schema applied"
);
Ok(())
}
@ -149,3 +161,13 @@ fn db_path_from_env() -> String {
fn chat_log_path_from_env() -> String {
std::env::var("KEI_BUDDY_CHAT_LOG_PATH").unwrap_or_else(|_| "./kei-buddy-chat.db".into())
}
fn topics_db_path_from_env() -> String {
std::env::var("KEI_BUDDY_TOPICS_DB_PATH")
.unwrap_or_else(|_| "./kei-buddy-topics.db".into())
}
fn contacts_db_path_from_env() -> String {
std::env::var("KEI_BUDDY_CONTACTS_DB_PATH")
.unwrap_or_else(|_| "./kei-buddy-contacts.db".into())
}

View file

@ -0,0 +1,111 @@
// SPDX-License-Identifier: Apache-2.0
//! Command execution helpers — one function per slash-command.
//! Called by `commands::execute_command`; not public API.
use crate::{chat_log::ChatLog, contacts::Contacts, topics::Topics};
pub(crate) async fn exec_topics(chat_id: i64, topics: &Topics) -> String {
match topics.list_topics(chat_id).await {
Err(e) => format!("ошибка при получении тем: {e}"),
Ok(list) if list.is_empty() => "тем пока нет".to_string(),
Ok(list) => {
let mut out = String::new();
for (i, unit) in list.iter().take(10).enumerate() {
let slug = unit.source_path.split('/').last().unwrap_or(&unit.source_path);
out.push_str(&format!("{}. {} ({})\n", i + 1, unit.title, slug));
}
out.trim_end().to_string()
}
}
}
pub(crate) async fn exec_contacts(contacts: &Contacts) -> String {
match contacts.search_contacts("", 10).await {
Err(_) => "контакты пусты".to_string(),
Ok(list) if list.is_empty() => "контакты пусты".to_string(),
Ok(list) => format_people(&list),
}
}
pub(crate) async fn exec_whois(name: &str, contacts: &Contacts) -> String {
if name.is_empty() {
return "использование: /whois <имя>".to_string();
}
match contacts.search_contacts(name, 5).await {
Err(e) => format!("ошибка поиска: {e}"),
Ok(list) if list.is_empty() => format!("не найдено никого по запросу '{name}'"),
Ok(list) => {
let mut out = format_people(&list);
append_common_connections(&mut out, &list, contacts).await;
out
}
}
}
async fn append_common_connections(
out: &mut String,
list: &[kei_social_store::people::Person],
contacts: &Contacts,
) {
if list.len() <= 1 {
return;
}
let top_id = list[0].id;
for hit in list.iter().skip(1).take(4) {
if let Ok(cc) = contacts.common_connections(top_id, hit.id).await {
if !cc.is_empty() {
let ids: Vec<String> = cc.iter().map(|id| id.to_string()).collect();
out.push_str(&format!(
"\nобщие знакомые ({} и {}): {}",
list[0].name,
hit.name,
ids.join(", ")
));
}
}
}
}
pub(crate) async fn exec_find(query: &str, chat_id: i64, chat_log: &ChatLog) -> String {
if query.is_empty() {
return "использование: /find <текст>".to_string();
}
match chat_log.search(query, Some(chat_id), 10).await {
Err(e) => format!("ошибка поиска в переписке: {e}"),
Ok(msgs) if msgs.is_empty() => "ничего не найдено в переписке".to_string(),
Ok(msgs) => {
let mut out = String::new();
for (i, msg) in msgs.iter().enumerate() {
let snippet = truncate(&msg.content, 80);
out.push_str(&format!("{}. [{}] {}\n", i + 1, msg.role, snippet));
}
out.trim_end().to_string()
}
}
}
pub(crate) fn format_people(list: &[kei_social_store::people::Person]) -> String {
let mut out = String::new();
for (i, p) in list.iter().enumerate() {
let detail = if !p.email.is_empty() {
p.email.clone()
} else if !p.organization.is_empty() {
p.organization.clone()
} else {
String::new()
};
if detail.is_empty() {
out.push_str(&format!("{}. {}\n", i + 1, p.name));
} else {
out.push_str(&format!("{}. {}{}\n", i + 1, p.name, detail));
}
}
out.trim_end().to_string()
}
fn truncate(s: &str, max_chars: usize) -> &str {
match s.char_indices().nth(max_chars) {
None => s,
Some((idx, _)) => &s[..idx],
}
}

View file

@ -0,0 +1,146 @@
// SPDX-License-Identifier: Apache-2.0
//! Slash-command public API: types, parser, and dispatcher.
//! Execution helpers live in `command_exec` (≤200 LOC split).
//! `process_text` in serve.rs calls `parse_command` BEFORE `handle_step`.
use std::sync::Arc;
use crate::{chat_log::ChatLog, command_exec as exec, contacts::Contacts, topics::Topics};
/// Recognised slash-commands. `None` = not a command → fall through to FSM.
pub enum Command<'a> {
Whois(&'a str),
Find(&'a str),
Topics,
Contacts,
Help,
}
/// Shared store references passed to `execute_command`.
pub struct CommandStores<'a> {
pub chat_log: &'a Arc<ChatLog>,
pub contacts: &'a Arc<Contacts>,
pub topics: &'a Arc<Topics>,
}
const HELP_TEXT: &str = "Доступные команды:\n\
/whois <имя> найти контакт\n\
/find <текст> поиск по переписке\n\
/topics список тем\n\
/contacts список контактов\n\
/help это сообщение";
/// Parse a raw user text into a Command, or None if it is not a slash-command.
pub fn parse_command(text: &str) -> Option<Command<'_>> {
let t = text.trim();
if !t.starts_with('/') {
return None;
}
let rest = &t[1..]; // drop leading '/'
if rest.eq_ignore_ascii_case("help") {
return Some(Command::Help);
}
if rest.eq_ignore_ascii_case("topics") {
return Some(Command::Topics);
}
if rest.eq_ignore_ascii_case("contacts") {
return Some(Command::Contacts);
}
let lower = rest.to_lowercase();
if lower.starts_with("whois") {
return Some(Command::Whois(rest[5..].trim()));
}
if lower.starts_with("find") {
return Some(Command::Find(rest[4..].trim()));
}
None
}
/// Execute a parsed command. Returns a human-readable response string.
/// All errors become human-readable messages (Russian, English fallback).
pub async fn execute_command(
cmd: Command<'_>,
chat_id: i64,
stores: &CommandStores<'_>,
) -> String {
match cmd {
Command::Help => HELP_TEXT.to_string(),
Command::Topics => exec::exec_topics(chat_id, stores.topics).await,
Command::Contacts => exec::exec_contacts(stores.contacts).await,
Command::Whois(name) => exec::exec_whois(name, stores.contacts).await,
Command::Find(query) => exec::exec_find(query, chat_id, stores.chat_log).await,
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn make_stores<'a>(
cl: &'a Arc<ChatLog>,
co: &'a Arc<Contacts>,
to: &'a Arc<Topics>,
) -> CommandStores<'a> {
CommandStores { chat_log: cl, contacts: co, topics: to }
}
#[test]
fn parse_help_no_args() {
assert!(matches!(parse_command("/help"), Some(Command::Help)));
}
#[test]
fn parse_whois_with_name() {
let cmd = parse_command("/whois Denis");
assert!(matches!(cmd, Some(Command::Whois("Denis"))));
}
#[test]
fn parse_non_command_returns_none() {
assert!(parse_command("hello there").is_none());
}
#[tokio::test]
async fn execute_help_returns_help_text() {
let cl = Arc::new(ChatLog::from_memory().unwrap());
let co = Arc::new(Contacts::from_memory().unwrap());
let to = Arc::new(Topics::from_memory().unwrap());
let stores = make_stores(&cl, &co, &to);
let resp = execute_command(Command::Help, 1, &stores).await;
assert!(resp.contains("/whois"));
assert!(resp.contains("/find"));
}
#[tokio::test]
async fn execute_topics_lists_added() {
let cl = Arc::new(ChatLog::from_memory().unwrap());
let co = Arc::new(Contacts::from_memory().unwrap());
let to = Arc::new(Topics::from_memory().unwrap());
to.add_topic(42, "rust-lang", "Rust Language", "content").await.unwrap();
let stores = make_stores(&cl, &co, &to);
let resp = execute_command(Command::Topics, 42, &stores).await;
assert!(resp.contains("Rust Language"));
}
#[tokio::test]
async fn execute_find_returns_matches() {
let cl = Arc::new(ChatLog::from_memory().unwrap());
let co = Arc::new(Contacts::from_memory().unwrap());
let to = Arc::new(Topics::from_memory().unwrap());
cl.log_user(99, "unique_search_word here").await.unwrap();
let stores = make_stores(&cl, &co, &to);
let resp = execute_command(Command::Find("unique_search_word"), 99, &stores).await;
assert!(resp.contains("unique_search_word"));
}
#[tokio::test]
async fn execute_contacts_empty_handled() {
let cl = Arc::new(ChatLog::from_memory().unwrap());
let co = Arc::new(Contacts::from_memory().unwrap());
let to = Arc::new(Topics::from_memory().unwrap());
let stores = make_stores(&cl, &co, &to);
let resp = execute_command(Command::Contacts, 1, &stores).await;
assert!(resp.contains("пусты") || resp.contains("контакт"));
}
}

View file

@ -11,6 +11,8 @@
//! * `store` — `BuddyStore` trait + `SqliteBuddyStore` impl
pub mod chat_log;
pub(crate) mod command_exec;
pub mod commands;
pub mod contacts;
pub mod error;
pub mod extractor;
@ -21,20 +23,26 @@ pub mod schema;
pub mod state;
pub mod store;
pub(crate) mod store_ops;
pub mod tick;
pub mod topic_classify;
pub mod topics;
pub mod transition;
#[cfg(feature = "serve")]
pub mod serve;
#[cfg(feature = "serve")]
pub(crate) mod serve_runner;
#[cfg(feature = "serve")]
pub mod serve_telegram;
pub use chat_log::ChatLog;
pub use commands::{parse_command, execute_command, Command, CommandStores};
pub use contacts::Contacts;
pub use error::BuddyError;
pub use extractor::LlmExtractor;
pub use machine::handle_step;
pub use state::OnboardState;
pub use store::{BuddyStore, SqliteBuddyStore};
pub use tick::{run_tick, run_tick_with, TickConfig, TickReport};
pub use topics::Topics;
pub use transition::StepOutput;

View file

@ -1,5 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
//! `run_serve` — axum router builder + BuddyContext impl.
//! `BuddyContext` + axum router. Store bootstrap lives in `serve_runner`.
//!
//! Constructor Pattern: one responsibility — compose crate pieces into HTTP server.
//! Each function ≤ 30 LOC. No logging of bot tokens.
@ -15,6 +15,8 @@ use kei_telegram_webhook::{WebhookContext, WebhookEvent};
use crate::{
chat_log::ChatLog,
commands::{execute_command, parse_command, CommandStores},
contacts::Contacts,
error::BuddyError,
extractor::LlmExtractor,
machine::handle_step,
@ -22,8 +24,12 @@ use crate::{
serve_telegram::send_message,
state::OnboardState,
store::{BuddyStore, SqliteBuddyStore},
topic_classify::classify_and_store_topic,
topics::Topics,
};
pub use crate::serve_runner::run_serve;
/// Configuration passed from the binary to `run_serve`.
pub struct ServeConfig {
pub port: u16,
@ -38,6 +44,10 @@ pub struct ServeConfig {
pub llm_model: Option<String>,
/// Path to the SQLite file used by `ChatLog`. Default: `./kei-buddy-chat.db`.
pub chat_log_db_path: String,
/// Path to the SQLite file used by `Topics`. Default: `./kei-buddy-topics.db`.
pub topics_db_path: String,
/// Path to the SQLite file used by `Contacts`. Default: `./kei-buddy-contacts.db`.
pub contacts_db_path: String,
}
/// Axum state — implements `WebhookContext`. `Arc<E>` allows cheap `Clone`.
@ -51,6 +61,10 @@ pub struct BuddyContext<E: LlmExtractor + Send + Sync + 'static> {
pub allowed_chat_ids: Arc<Option<Vec<i64>>>,
/// Persistent log of all Telegram messages (user + bot).
pub chat_log: Arc<ChatLog>,
/// Persistent topic store.
pub topics: Arc<Topics>,
/// Persistent contacts store.
pub contacts: Arc<Contacts>,
}
impl<E: LlmExtractor + Send + Sync + 'static> Clone for BuddyContext<E> {
@ -63,6 +77,8 @@ impl<E: LlmExtractor + Send + Sync + 'static> Clone for BuddyContext<E> {
http: self.http.clone(),
allowed_chat_ids: Arc::clone(&self.allowed_chat_ids),
chat_log: Arc::clone(&self.chat_log),
topics: Arc::clone(&self.topics),
contacts: Arc::clone(&self.contacts),
}
}
}
@ -107,19 +123,38 @@ impl<E: LlmExtractor + Send + Sync + 'static> BuddyContext<E> {
if let Err(e) = self.chat_log.log_user(chat_id, text).await {
error!(chat_id, error = %e, "chat_log failure");
}
let state = self
.store
.load_state(chat_id)
.await?
.unwrap_or(OnboardState::Intro);
let persona = self
.store
.load_persona(chat_id)
.await?
.unwrap_or_else(|| serde_json::json!({}));
if let Some(cmd) = parse_command(text) {
return self.dispatch_command(cmd, chat_id).await;
}
self.run_fsm(chat_id, text).await
}
async fn dispatch_command(
&self, cmd: crate::commands::Command<'_>, chat_id: i64,
) -> Result<(), BuddyError> {
let stores = CommandStores {
chat_log: &self.chat_log,
contacts: &self.contacts,
topics: &self.topics,
};
let response = execute_command(cmd, chat_id, &stores).await;
let _ = send_message(&self.bot_token, chat_id, &response, &self.http).await;
if let Err(e) = self.chat_log.log_bot(chat_id, &response).await {
error!(chat_id, error = %e, "chat_log failure");
}
Ok(())
}
async fn run_fsm(&self, chat_id: i64, text: &str) -> Result<(), BuddyError> {
let state = self.store.load_state(chat_id).await?.unwrap_or(OnboardState::Intro);
let was_ready = state == OnboardState::Ready;
let persona = self.store.load_persona(chat_id).await?.unwrap_or_else(|| json!({}));
let output = handle_step(&state, text, &persona, self.extractor.as_ref()).await?;
self.store.save_state(chat_id, &output.next_state).await?;
self.apply_persona_patch(chat_id, output.persona_patch).await?;
if was_ready || output.next_state == OnboardState::Ready {
classify_and_store_topic(self.extractor.as_ref(), self.topics.as_ref(), chat_id, text).await;
}
send_message(&self.bot_token, chat_id, &output.response_text, &self.http).await?;
if let Err(e) = self.chat_log.log_bot(chat_id, &output.response_text).await {
error!(chat_id, error = %e, "chat_log failure");
@ -131,11 +166,7 @@ impl<E: LlmExtractor + Send + Sync + 'static> BuddyContext<E> {
if patch == json!({}) {
return Ok(());
}
let base = self
.store
.load_persona(chat_id)
.await?
.unwrap_or_else(|| json!({}));
let base = self.store.load_persona(chat_id).await?.unwrap_or_else(|| json!({}));
let merged = deep_merge(base, patch);
self.store.save_persona(chat_id, &merged).await
}
@ -143,11 +174,7 @@ impl<E: LlmExtractor + Send + Sync + 'static> BuddyContext<E> {
/// Health-check handler.
async fn health() -> Json<Value> {
Json(json!({
"status": "ok",
"crate": "kei-buddy",
"version": env!("CARGO_PKG_VERSION")
}))
Json(json!({ "status": "ok", "crate": "kei-buddy", "version": env!("CARGO_PKG_VERSION") }))
}
/// Build the axum Router.
@ -156,72 +183,7 @@ where
E: LlmExtractor + Send + Sync + 'static,
{
Router::new()
.route(
"/webhook",
routing::post(kei_telegram_webhook::handle_webhook::<BuddyContext<E>>),
)
.route("/webhook", routing::post(kei_telegram_webhook::handle_webhook::<BuddyContext<E>>))
.route("/health", routing::get(health))
.with_state(ctx)
}
/// Start the HTTP server.
pub async fn run_serve(cfg: ServeConfig) -> anyhow::Result<()> {
init_tracing();
let store = Arc::new(SqliteBuddyStore::from_path(&cfg.db_path)?);
let allowed_chat_ids = Arc::new(cfg.allowed_chat_ids);
let http = reqwest::Client::new();
let chat_log = Arc::new(ChatLog::from_path(&cfg.chat_log_db_path)?);
#[cfg(feature = "extractor-openai")]
{
if let (Some(proxy), Some(key)) = (cfg.llm_proxy_url, cfg.llm_api_key) {
let model = cfg
.llm_model
.unwrap_or_else(|| "gpt-4o-mini".to_string());
tracing::info!(model = %model, "using OpenAiExtractor (LiteLLM-compatible)");
let extractor = Arc::new(crate::extractor::openai::OpenAiExtractor::new_with_model(
proxy, key, model,
));
return start_listener(cfg.port, BuddyContext {
secret: cfg.webhook_secret,
bot_token: cfg.bot_token,
store,
extractor,
http,
allowed_chat_ids,
chat_log,
}).await;
}
}
warn!("no LLM extractor configured — using MockExtractor (state machine will advance but field-extraction returns empty)");
let extractor = Arc::new(crate::extractor::MockExtractor::new(json!({})));
start_listener(cfg.port, BuddyContext {
secret: cfg.webhook_secret,
bot_token: cfg.bot_token,
store,
extractor,
http,
allowed_chat_ids,
chat_log,
}).await
}
async fn start_listener<E>(port: u16, ctx: BuddyContext<E>) -> anyhow::Result<()>
where
E: LlmExtractor + Send + Sync + 'static,
{
let router = build_router(ctx);
let addr = format!("0.0.0.0:{}", port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!(addr = %addr, "kei-buddy listening");
axum::serve(listener, router).await?;
Ok(())
}
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let _ = fmt()
.with_env_filter(EnvFilter::from_default_env())
.try_init();
}

View file

@ -0,0 +1,69 @@
// SPDX-License-Identifier: Apache-2.0
//! `run_serve` — store construction + HTTP listener bootstrap.
//! Extracted from serve.rs to keep both files ≤ 200 LOC.
use std::sync::Arc;
use serde_json::json;
use tracing::warn;
use crate::{
chat_log::ChatLog,
contacts::Contacts,
extractor::LlmExtractor,
serve::{BuddyContext, ServeConfig},
store::SqliteBuddyStore,
topics::Topics,
};
/// Start the HTTP server (entry-point called from the binary).
pub async fn run_serve(cfg: ServeConfig) -> anyhow::Result<()> {
init_tracing();
let store = Arc::new(SqliteBuddyStore::from_path(&cfg.db_path)?);
let allowed_chat_ids = Arc::new(cfg.allowed_chat_ids);
let http = reqwest::Client::new();
let chat_log = Arc::new(ChatLog::from_path(&cfg.chat_log_db_path)?);
let topics = Arc::new(Topics::from_path(&cfg.topics_db_path)?);
let contacts = Arc::new(Contacts::from_path(&cfg.contacts_db_path)?);
#[cfg(feature = "extractor-openai")]
{
if let (Some(proxy), Some(key)) = (cfg.llm_proxy_url, cfg.llm_api_key) {
let model = cfg.llm_model.unwrap_or_else(|| "gpt-4o-mini".to_string());
tracing::info!(model = %model, "using OpenAiExtractor (LiteLLM-compatible)");
let extractor = Arc::new(crate::extractor::openai::OpenAiExtractor::new_with_model(
proxy, key, model,
));
return start_listener(cfg.port, BuddyContext {
secret: cfg.webhook_secret,
bot_token: cfg.bot_token,
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts,
}).await;
}
}
warn!("no LLM extractor configured — using MockExtractor");
let extractor = Arc::new(crate::extractor::MockExtractor::new(json!({})));
start_listener(cfg.port, BuddyContext {
secret: cfg.webhook_secret,
bot_token: cfg.bot_token,
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts,
}).await
}
async fn start_listener<E>(port: u16, ctx: BuddyContext<E>) -> anyhow::Result<()>
where
E: LlmExtractor + Send + Sync + 'static,
{
let router = crate::serve::build_router(ctx);
let addr = format!("0.0.0.0:{}", port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!(addr = %addr, "kei-buddy listening");
axum::serve(listener, router).await?;
Ok(())
}
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let _ = fmt().with_env_filter(EnvFilter::from_default_env()).try_init();
}

View file

@ -66,6 +66,14 @@ impl SqliteBuddyStore {
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 ─────────────────────────────────────────────────────────

View file

@ -0,0 +1,188 @@
// SPDX-License-Identifier: Apache-2.0
//! `run_tick` — one-shot digest generator for all tracked topics.
//!
//! Constructor Pattern: one responsibility — walk topics, collect recent
//! messages, call LLM, persist digests. Called from `kei-buddy-tick` bin.
use std::sync::Arc;
use crate::{
chat_log::ChatLog,
error::BuddyError,
extractor::LlmExtractor,
topics::Topics,
};
// ─── public types ────────────────────────────────────────────────────────────
/// Configuration for a `run_tick` invocation (file paths + tuning knobs).
pub struct TickConfig {
pub buddy_db_path: String,
pub chat_log_db_path: String,
pub topics_db_path: String,
/// Only messages newer than `now - since_hours * 3600` are included.
pub since_hours: i64,
pub max_messages_per_topic: i64,
pub llm_proxy_url: Option<String>,
pub llm_api_key: Option<String>,
pub llm_model: Option<String>,
}
/// Summary returned after one tick run.
#[derive(Debug, Default)]
pub struct TickReport {
pub topics_processed: usize,
pub digests_created: usize,
pub errors: Vec<String>,
}
// ─── public entry points ─────────────────────────────────────────────────────
/// Open DBs from `cfg` paths, discover chat_ids, run digests.
///
/// Exits 0 on per-topic errors — they are collected in `TickReport.errors`.
pub async fn run_tick(cfg: TickConfig) -> Result<TickReport, BuddyError> {
let chat_log = Arc::new(ChatLog::from_path(&cfg.chat_log_db_path)?);
let topics = Arc::new(Topics::from_path(&cfg.topics_db_path)?);
let extractor = build_extractor(&cfg);
let chat_ids = load_chat_ids(&cfg.buddy_db_path)?;
run_tick_with(chat_log, topics, extractor, cfg.since_hours, cfg.max_messages_per_topic, chat_ids).await
}
/// Testable core: accepts pre-built instances and an explicit chat_id list.
pub async fn run_tick_with(
chat_log: Arc<ChatLog>,
topics: Arc<Topics>,
extractor: Arc<dyn LlmExtractor>,
since_hours: i64,
max_messages: i64,
known_chat_ids: Vec<i64>,
) -> Result<TickReport, BuddyError> {
let mut report = TickReport::default();
let cutoff = now_epoch() - since_hours * 3600;
for chat_id in known_chat_ids {
let topic_units = match topics.list_topics(chat_id).await {
Ok(v) => v,
Err(e) => { report.errors.push(format!("list_topics({chat_id}): {e}")); continue; }
};
for unit in topic_units {
report.topics_processed += 1;
process_topic(&chat_log, &topics, &*extractor, chat_id, &unit, cutoff, max_messages, &mut report).await;
}
}
Ok(report)
}
/// Collect recent messages for one topic, call extractor, persist digest.
async fn process_topic(
chat_log: &ChatLog,
topics: &Topics,
extractor: &dyn LlmExtractor,
chat_id: i64,
unit: &kei_sage::Unit,
cutoff: i64,
max_messages: i64,
report: &mut TickReport,
) {
let slug = slug_from_path(&unit.source_path);
let msgs = match chat_log.search(&unit.title, Some(chat_id), max_messages).await {
Ok(v) => v,
Err(e) => { report.errors.push(format!("search({chat_id}, {}): {e}", unit.title)); return; }
};
let recent: Vec<_> = msgs.into_iter().filter(|m| m.created_at >= cutoff).collect();
if recent.is_empty() { return; }
let val = match extractor.extract(&digest_system_prompt(&unit.title), &concat_messages(&recent)).await {
Ok(v) => v,
Err(e) => { report.errors.push(format!("extract({chat_id}, {}): {e}", unit.title)); return; }
};
if val.is_null() || val.as_object().map(|m| m.is_empty()).unwrap_or(false) {
report.errors.push(format!("extractor empty for '{}' in chat {chat_id}", unit.title));
return;
}
let text = val.as_str().map(|s| s.to_string()).unwrap_or_else(|| val.to_string());
match topics.add_digest(chat_id, slug, now_epoch(), &text).await {
Ok(_) => report.digests_created += 1,
Err(e) => report.errors.push(format!("add_digest({chat_id}, {slug}): {e}")),
}
}
// ─── helpers ──────────────────────────────────────────────────────────────────
fn now_epoch() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn slug_from_path(source_path: &str) -> &str {
source_path.rfind("topic/").map(|i| &source_path[i + 6..]).unwrap_or(source_path)
}
fn concat_messages(msgs: &[kei_chat_store::sessions::ChatMessage]) -> String {
let mut out = String::new();
for m in msgs {
out.push_str(&m.content);
out.push('\n');
if out.len() > 2000 { break; }
}
out.truncate(2000);
out
}
fn digest_system_prompt(title: &str) -> String {
format!(
"You are a digest writer. Summarise the following messages about \"{title}\" \
into 2-4 bullet points in the user's language. Output plain text, no markdown \
headers, no preamble."
)
}
/// Read distinct chat_ids from buddy_state table.
fn load_chat_ids(buddy_db_path: &str) -> Result<Vec<i64>, BuddyError> {
use crate::store::SqliteBuddyStore;
let store = SqliteBuddyStore::from_path(buddy_db_path)?;
load_chat_ids_from_store(&store)
}
/// Extract chat_ids from a BuddyStore (visible for testing via in-memory store).
pub fn load_chat_ids_from_store(store: &crate::store::SqliteBuddyStore) -> Result<Vec<i64>, BuddyError> {
let conn = store.inner_conn();
let mut stmt = conn.prepare("SELECT DISTINCT chat_id FROM buddy_state")
.map_err(|e| BuddyError::Memory(e.to_string()))?;
let ids: Vec<i64> = stmt
.query_map([], |r| r.get(0))
.map_err(|e| BuddyError::Memory(e.to_string()))?
.filter_map(|r| r.ok())
.collect();
Ok(ids)
}
fn build_extractor(cfg: &TickConfig) -> Arc<dyn LlmExtractor> {
#[cfg(feature = "extractor-openai")]
{
let proxy = cfg.llm_proxy_url.clone().or_else(|| Some("https://api.openai.com".to_string()));
let key = cfg.llm_api_key.clone().or_else(|| std::env::var("OPENAI_API_KEY").ok());
if let (Some(proxy_url), Some(api_key)) = (proxy, key) {
use crate::extractor::openai::OpenAiExtractor;
let ext = match &cfg.llm_model {
Some(m) => OpenAiExtractor::new_with_model(proxy_url, api_key, m.clone()),
None => OpenAiExtractor::new(proxy_url, api_key),
};
return Arc::new(ext);
}
}
let _ = cfg; // suppress unused warning when feature off
Arc::new(NoOpExtractor)
}
/// Fallback extractor when `extractor-openai` is not compiled or creds are absent.
struct NoOpExtractor;
#[async_trait::async_trait]
impl LlmExtractor for NoOpExtractor {
async fn extract(&self, _system: &str, _user_text: &str) -> Result<serde_json::Value, BuddyError> {
Ok(serde_json::Value::Null)
}
}

View file

@ -0,0 +1,109 @@
// SPDX-License-Identifier: Apache-2.0
//! Topic classification helper — free function invoked after `OnboardState::Ready`.
//!
//! Constructor Pattern: one responsibility — LLM classify + Topics store, fire-and-forget.
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{error, warn};
use crate::{extractor::LlmExtractor, topics::Topics};
const CLASSIFY_PROMPT: &str = concat!(
"You are a topic classifier. Output a single JSON object with two string fields: ",
"\"slug\" (kebab-case, ascii, ≤30 chars, like \"work-meetings\") and ",
"\"title\" (human-readable in the user's language, ≤50 chars). ",
"Classify the following user message into ONE topic. ",
"Output only the JSON, no prose, no markdown fences."
);
/// Classify `text` into a topic and store it in `topics`. Never panics; never returns `Err`.
pub async fn classify_and_store_topic(
extractor: &dyn LlmExtractor,
topics: &Topics,
chat_id: i64,
text: &str,
) {
let val = match extractor.extract(CLASSIFY_PROMPT, text).await {
Ok(v) => v,
Err(e) => {
warn!(chat_id, error = %e, "topic classifier LLM call failed");
return;
}
};
let slug = match val.get("slug").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
warn!(chat_id, "topic classifier returned no slug field");
return;
}
};
let title = match val.get("title").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
warn!(chat_id, "topic classifier returned no title field");
return;
}
};
if !is_valid_slug(&slug) {
warn!(chat_id, slug = %slug, "topic slug failed validation; skipping");
return;
}
if let Err(e) = topics.add_topic(chat_id, &slug, &title, text).await {
error!(chat_id, slug = %slug, error = %e, "topics.add_topic failed");
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if let Err(e) = topics.add_digest(chat_id, &slug, now, text).await {
error!(chat_id, slug = %slug, error = %e, "topics.add_digest failed");
}
}
fn is_valid_slug(slug: &str) -> bool {
!slug.is_empty()
&& slug.len() <= 40
&& slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::{extractor::MockExtractor, topics::Topics};
use serde_json::json;
async fn make_topics() -> Topics {
Topics::from_memory().unwrap()
}
#[tokio::test]
async fn classify_and_store_skips_invalid_slug() {
let extractor = MockExtractor::new(json!({"slug": "has spaces", "title": "X"}));
let topics = make_topics().await;
classify_and_store_topic(&extractor, &topics, 1, "hello").await;
assert!(topics.list_topics(1).await.unwrap().is_empty());
}
#[tokio::test]
async fn classify_and_store_adds_topic_for_valid_slug() {
let extractor = MockExtractor::new(json!({"slug": "work-stuff", "title": "Work Stuff"}));
let topics = make_topics().await;
classify_and_store_topic(&extractor, &topics, 1, "I have a meeting").await;
assert_eq!(topics.list_topics(1).await.unwrap().len(), 1);
}
#[tokio::test]
async fn classify_and_store_handles_missing_fields() {
let extractor = MockExtractor::new(json!({}));
let topics = make_topics().await;
classify_and_store_topic(&extractor, &topics, 1, "any text").await;
assert!(topics.list_topics(1).await.unwrap().is_empty());
}
}

View file

@ -0,0 +1,79 @@
// SPDX-License-Identifier: Apache-2.0
//! Integration tests for `tick::run_tick_with`.
use std::sync::Arc;
use kei_buddy::{
chat_log::ChatLog,
extractor::{LlmExtractor, MockExtractor},
run_tick_with,
topics::Topics,
};
use serde_json::json;
fn mock(v: serde_json::Value) -> Arc<dyn LlmExtractor> {
Arc::new(MockExtractor::new(v))
}
#[tokio::test]
async fn tick_no_topics_returns_zero() {
let chat_log = Arc::new(ChatLog::from_memory().unwrap());
let topics = Arc::new(Topics::from_memory().unwrap());
let report = run_tick_with(chat_log, topics, mock(json!("digest")), 24, 50, vec![42])
.await
.unwrap();
assert_eq!(report.topics_processed, 0);
assert_eq!(report.digests_created, 0);
assert!(report.errors.is_empty());
}
#[tokio::test]
async fn tick_with_topic_and_messages_creates_digest() {
let chat_log = Arc::new(ChatLog::from_memory().unwrap());
let topics = Arc::new(Topics::from_memory().unwrap());
// Title "rust" so FTS search on "rust" matches messages containing "rust".
topics.add_topic(42, "rust", "rust", "about rust").await.unwrap();
chat_log.log_user(42, "I love rust borrow checker").await.unwrap();
chat_log.log_user(42, "rust ownership is great").await.unwrap();
let report = run_tick_with(
chat_log,
topics.clone(),
mock(json!("• Rust is great\n• Ownership rules")),
24,
50,
vec![42],
)
.await
.unwrap();
assert_eq!(report.topics_processed, 1, "errors: {:?}", report.errors);
assert_eq!(report.digests_created, 1, "errors: {:?}", report.errors);
let digests = topics.digests_for(42, "rust").await.unwrap();
assert_eq!(digests.len(), 1);
}
#[tokio::test]
async fn tick_skips_topic_without_recent_messages() {
let chat_log = Arc::new(ChatLog::from_memory().unwrap());
let topics = Arc::new(Topics::from_memory().unwrap());
topics.add_topic(42, "go", "Go Programming", "about go").await.unwrap();
// No messages added — topic has 0 recent messages, skip digest.
let report = run_tick_with(
chat_log,
topics,
mock(json!("• Go is fine")),
24,
50,
vec![42],
)
.await
.unwrap();
assert_eq!(report.topics_processed, 1);
assert_eq!(report.digests_created, 0);
}

View file

@ -29,6 +29,25 @@ fn extract_wikilinks(content: &str) -> Vec<String> {
.collect()
}
/// Normalize a wikilink target to a basename comparable against
/// `all_basenames` (file_stem-based index).
///
/// Returns `None` when the target escapes the scan root via `../` —
/// such refs point outside the scan tree (e.g. `~/.claude/rules/*` from
/// inside a sync-repo MEMORY.md) and cannot be validated by this scanner.
///
/// For path-prefixed targets (`chatlogs/X/Y`, `concepts/Z`) only the
/// last segment is compared, matching how `all_basenames` builds its
/// index. The `.md` suffix is stripped — `file_stem` does the same.
fn normalize_target(raw: &str) -> Option<String> {
if raw.starts_with("../") || raw.contains("/../") {
return None;
}
let bn = raw.rsplit('/').next().unwrap_or(raw);
let bn = bn.strip_suffix(".md").unwrap_or(bn);
Some(bn.to_string())
}
fn extract_handoffs(content: &str) -> Vec<String> {
let rx = Regex::new(r"(?im)^\s*-\s*\*\*([a-z0-9][a-z0-9_-]{2,})\*\*").expect("static regex");
rx.captures_iter(content)
@ -48,9 +67,12 @@ pub fn scan(root: &Path) -> Vec<Conflict> {
}
let content = read_lossy(e.path());
let file_rel = rel(root, e.path());
for target in extract_wikilinks(&content) {
if !index.contains(&target) {
out.push(orphan(&file_rel, &target, "wikilink"));
for raw in extract_wikilinks(&content) {
let Some(normalized) = normalize_target(&raw) else {
continue;
};
if !index.contains(&normalized) {
out.push(orphan(&file_rel, &raw, "wikilink"));
}
}
for target in extract_handoffs(&content) {
@ -72,3 +94,43 @@ fn orphan(file: &str, target: &str, kind: &str) -> Conflict {
true,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cross_repo_ref_skipped() {
assert_eq!(normalize_target("../../../rules/recurrence-escalate"), None);
assert_eq!(normalize_target("../foo"), None);
assert_eq!(normalize_target("docs/../escape"), None);
}
#[test]
fn path_prefixed_target_basenamed() {
assert_eq!(
normalize_target("chatlogs/ml-keilab/2026-05-08-something"),
Some("2026-05-08-something".to_string())
);
assert_eq!(
normalize_target("concepts/keibeta"),
Some("keibeta".to_string())
);
}
#[test]
fn plain_basename_passes_through() {
assert_eq!(
normalize_target("ai-creative-engine"),
Some("ai-creative-engine".to_string())
);
}
#[test]
fn md_suffix_stripped() {
assert_eq!(
normalize_target("docs/intro.md"),
Some("intro".to_string())
);
}
}

View file

@ -62,6 +62,26 @@ fn orphan_wikilinks_flagged() {
assert_eq!(v["conflicts"][0]["category"], "orphans");
}
#[test]
fn cross_repo_wikilink_not_flagged() {
// `[[../../../rules/X]]` escapes the scan root — engine cannot validate,
// must not false-positive.
let tmp = TempDir::new().unwrap();
write(tmp.path(), "memory/MEMORY.md", "see [[../../../rules/recurrence-escalate]]");
let v = run(tmp.path(), &["--only", "orphans"]);
assert_eq!(v["hit_count"].as_u64().unwrap(), 0, "{}", v);
}
#[test]
fn path_prefixed_wikilink_matches_basename() {
// `[[chatlogs/X/Y]]` should resolve when `Y.md` exists anywhere in the tree.
let tmp = TempDir::new().unwrap();
write(tmp.path(), "chatlogs/X/Y.md", "target body");
write(tmp.path(), "memory/index.md", "ref to [[chatlogs/X/Y]]");
let v = run(tmp.path(), &["--only", "orphans"]);
assert_eq!(v["hit_count"].as_u64().unwrap(), 0, "{}", v);
}
#[test]
fn oversize_file_flagged() {
let tmp = TempDir::new().unwrap();

View file

@ -0,0 +1,31 @@
[package]
name = "kei-contacts-apple"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
description = "CardDAV client for iCloud Contacts. Authenticates with Apple ID + app-specific password (HTTP Basic Auth)."
authors.workspace = true
license.workspace = true
[lib]
name = "kei_contacts_apple"
path = "src/lib.rs"
[dependencies]
serde = { workspace = true, features = ["derive"] }
thiserror = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true, features = ["macros"] }
tracing = "0.1"
regex = { workspace = true }
kei-social-store = { path = "../kei-social-store" }
[dev-dependencies]
wiremock = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
[package.metadata.keisei]
maturity = "alpha"
backend = "apple"
description = "iCloud CardDAV thin client (Basic Auth, Vec<AppleContact>-out)"
authors = ["Denis Parfionovich <parfionovich@keilab.io>"]

View file

@ -0,0 +1,52 @@
# kei-contacts-apple
CardDAV client for iCloud Contacts.
## Authentication
iCloud Contacts uses **app-specific passwords**, not OAuth / Sign in with Apple
(that scope does not include contacts access).
1. Go to <https://appleid.apple.com> → Sign-in and Security → App-Specific Passwords.
2. Click **Generate an app-specific password**.
3. Copy the generated password (format: `xxxx-xxxx-xxxx-xxxx`).
Use your Apple ID e-mail and this password with the client.
## Addressbook URL
Full CardDAV discovery is not yet implemented. Look up your addressbook URL from
a CalDAV/CardDAV client (e.g. Thunderbird → Settings → Address Books → iCloud
account → right-click → Properties) or use the standard iCloud pattern:
```
https://pXX-contacts.icloud.com/<DSID>/carddavhome/card/
```
Where `pXX` is your regional shard and `DSID` is your iCloud account ID.
## Usage
```rust
let client = kei_contacts_apple::ICloudCardDavClient::new(
"user@icloud.com".to_string(),
std::env::var("APPLE_APP_SPECIFIC_PASSWORD").unwrap(),
)
.with_addressbook_url(
"https://p01-contacts.icloud.com/123456789/carddavhome/card/".to_string(),
);
let contacts = client.list_contacts().await?;
for c in &contacts {
let person = c.to_person(); // -> kei_social_store::people::Person
println!("{} <{}>", person.name, person.email);
}
```
## Limitations
- Line-folding in vCards (continuation lines starting with a space) is not handled.
- CardDAV discovery (`PROPFIND .well-known/carddav`) is not implemented; supply
the addressbook URL directly via `with_addressbook_url`.
- Pagination is not implemented; all contacts in the addressbook are returned in
a single REPORT request.

View file

@ -0,0 +1,168 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! [`ICloudCardDavClient`] — CardDAV client for iCloud Contacts.
use crate::contact::AppleContact;
use crate::error::ContactsError;
use crate::xml::{addressbook_query_xml, extract_contacts_from_multistatus};
use reqwest::{Client, Method};
use tracing::debug;
const DEFAULT_BASE_URL: &str = "https://contacts.icloud.com";
/// CardDAV client for iCloud Contacts.
///
/// # Authentication
/// iCloud requires an **app-specific password** (not the main Apple ID password
/// and not Sign in with Apple). Generate one at <https://appleid.apple.com>.
///
/// # Discovery
/// Full CardDAV discovery (PROPFIND `.well-known/carddav`) is complex. For the
/// MVP, supply the addressbook URL directly via [`with_addressbook_url`].
///
/// [`with_addressbook_url`]: ICloudCardDavClient::with_addressbook_url
pub struct ICloudCardDavClient {
apple_id: String,
app_specific_password: String,
base_url: String,
addressbook_url: Option<String>,
client: Client,
}
impl ICloudCardDavClient {
/// Construct a client with the given credentials and the production base URL.
pub fn new(apple_id: String, app_specific_password: String) -> Self {
Self {
apple_id,
app_specific_password,
base_url: DEFAULT_BASE_URL.to_string(),
addressbook_url: None,
client: Client::new(),
}
}
/// Override the base URL (useful for wiremock tests).
pub fn with_base_url(mut self, url: String) -> Self {
self.base_url = url;
self
}
/// Set the full addressbook URL, skipping CardDAV discovery.
///
/// Example (iCloud): `https://p01-contacts.icloud.com/123456789/carddavhome/card/`
pub fn with_addressbook_url(mut self, url: String) -> Self {
self.addressbook_url = Some(url);
self
}
/// Fetch all contacts from the configured addressbook.
///
/// Issues a CardDAV REPORT `addressbook-query` and returns parsed contacts.
pub async fn list_contacts(&self) -> Result<Vec<AppleContact>, ContactsError> {
let url = self
.addressbook_url
.clone()
.unwrap_or_else(|| self.base_url.clone());
debug!(%url, "REPORT addressbook-query");
let resp = self
.client
.request(
Method::from_bytes(b"REPORT")
.map_err(|e| ContactsError::Http(e.to_string()))?,
&url,
)
.basic_auth(&self.apple_id, Some(&self.app_specific_password))
.header("Content-Type", "application/xml; charset=utf-8")
.header("Depth", "1")
.body(addressbook_query_xml())
.send()
.await
.map_err(|e| ContactsError::Http(e.to_string()))?;
let status = resp.status();
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(ContactsError::Auth(format!(
"iCloud returned {}",
status.as_u16()
)));
}
if !status.is_success() && status.as_u16() != 207 {
return Err(ContactsError::Http(format!("status={}", status)));
}
let text = resp
.text()
.await
.map_err(|e| ContactsError::Parse(e.to_string()))?;
extract_contacts_from_multistatus(&text)
}
}
// ── tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn two_contacts_xml() -> String {
let vc1 = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alice Smith\r\nUID:uid-alice\r\nEMAIL:alice@example.com\r\nEND:VCARD";
let vc2 = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bob Jones\r\nUID:uid-bob\r\nEMAIL:bob@example.com\r\nEND:VCARD";
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:response>
<D:propstat><C:address-data>{vc1}</C:address-data></D:propstat>
</D:response>
<D:response>
<D:propstat><C:address-data>{vc2}</C:address-data></D:propstat>
</D:response>
</D:multistatus>"#
)
}
#[tokio::test]
async fn list_contacts_parses_carddav_xml() {
let server = MockServer::start().await;
Mock::given(method("REPORT"))
.and(path("/"))
.respond_with(ResponseTemplate::new(207).set_body_string(two_contacts_xml()))
.mount(&server)
.await;
let client = ICloudCardDavClient::new(
"user@icloud.com".to_string(),
"app-pass".to_string(),
)
.with_base_url(server.uri());
let contacts = client.list_contacts().await.expect("should succeed");
assert_eq!(contacts.len(), 2);
let names: Vec<_> = contacts.iter().map(|c| c.display_name.as_str()).collect();
assert!(names.contains(&"Alice Smith"));
assert!(names.contains(&"Bob Jones"));
}
#[tokio::test]
async fn auth_401_maps_to_auth_error() {
let server = MockServer::start().await;
Mock::given(method("REPORT"))
.and(path("/"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = ICloudCardDavClient::new(
"user@icloud.com".to_string(),
"wrong-pass".to_string(),
)
.with_base_url(server.uri());
let err = client.list_contacts().await.expect_err("should fail");
assert!(matches!(err, ContactsError::Auth(_)));
}
}

View file

@ -0,0 +1,97 @@
// 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");
}
}

View file

@ -0,0 +1,25 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! `ContactsError` — typed error variants for the iCloud CardDAV client.
use thiserror::Error;
/// Errors produced by [`crate::ICloudCardDavClient`].
#[derive(Debug, Error)]
pub enum ContactsError {
/// Network or non-auth HTTP error.
#[error("http error: {0}")]
Http(String),
/// Authentication failure (401 or 403 from iCloud).
#[error("auth error: {0}")]
Auth(String),
/// XML or response body parsing failure.
#[error("parse error: {0}")]
Parse(String),
/// vCard content could not be parsed into a contact.
#[error("invalid vcard: {0}")]
InvalidVCard(String),
}

View file

@ -0,0 +1,35 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! `kei-contacts-apple` — CardDAV client for iCloud Contacts.
//!
//! Authenticates with **Apple ID + app-specific password** (HTTP Basic Auth).
//! Does NOT use Sign in with Apple / OAuth — that scope does not cover contacts.
//!
//! # Quick start
//! ```no_run
//! # async fn example() -> Result<(), kei_contacts_apple::ContactsError> {
//! let client = kei_contacts_apple::ICloudCardDavClient::new(
//! "user@icloud.com".to_string(),
//! std::env::var("APPLE_APP_SPECIFIC_PASSWORD").unwrap(),
//! )
//! .with_addressbook_url(
//! "https://p01-contacts.icloud.com/123456789/carddavhome/card/".to_string(),
//! );
//! let contacts = client.list_contacts().await?;
//! for c in &contacts {
//! let person = c.to_person();
//! println!("{} <{}>", person.name, person.email);
//! }
//! # Ok(())
//! # }
//! ```
pub mod client;
pub mod contact;
pub mod error;
pub mod vcard;
pub(crate) mod xml;
pub use client::ICloudCardDavClient;
pub use contact::AppleContact;
pub use error::ContactsError;

View file

@ -0,0 +1,140 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! Minimal vCard 3.0 / 4.0 parser.
//!
//! # Limitations (MVP)
//! - **Line-folding** (continuation lines starting with a single space or tab)
//! is **not** handled. Such lines are treated as separate malformed lines and
//! silently skipped. This covers the vast majority of iCloud-generated vCards.
//! - Only a fixed set of properties (FN, N, EMAIL, TEL, ORG, NOTE, UID) is extracted.
//! - Property parameters (e.g. `TYPE=INTERNET`) are stripped; only the value is kept.
//! - Multi-valued ORG (e.g. `ORG:Company;Department`) uses the first segment.
use crate::contact::AppleContact;
use crate::error::ContactsError;
/// Parse a single vCard text into an [`AppleContact`].
///
/// `text` must be the content of one vCard (between `BEGIN:VCARD` and `END:VCARD`
/// inclusive).
pub fn parse_vcard(text: &str) -> Result<AppleContact, ContactsError> {
let mut contact = AppleContact {
raw_vcard: text.to_string(),
..Default::default()
};
for line in text.lines() {
let line = line.trim_end_matches('\r');
// Skip continuation lines (line-folding, not handled in MVP).
if line.starts_with(' ') || line.starts_with('\t') {
continue;
}
let Some((key_full, value)) = line.split_once(':') else {
continue;
};
// Strip parameters: key_full may be "EMAIL;TYPE=INTERNET" → key = "EMAIL"
let key = key_full
.split(';')
.next()
.unwrap_or(key_full)
.to_ascii_uppercase();
let value = value.trim();
apply_property(&key, value, &mut contact);
}
if contact.uid.is_empty() && contact.display_name.is_empty() {
return Err(ContactsError::InvalidVCard(
"no UID or FN found in vCard".to_string(),
));
}
Ok(contact)
}
fn apply_property(key: &str, value: &str, c: &mut AppleContact) {
match key {
"FN" => c.display_name = value.to_string(),
"UID" => c.uid = value.to_string(),
"NOTE" => c.note = value.to_string(),
"EMAIL" => {
if !value.is_empty() {
c.emails.push(value.to_string());
}
}
"TEL" => {
if !value.is_empty() {
c.phones.push(value.to_string());
}
}
"N" => parse_n(value, c),
"ORG" => {
// ORG may be "Company;Department;..." — take first segment.
let org = value.split(';').next().unwrap_or(value);
if !org.is_empty() {
c.organization = org.to_string();
}
}
_ => {}
}
}
/// Parse vCard N property: `family;given;additional;prefix;suffix`
fn parse_n(value: &str, c: &mut AppleContact) {
let mut parts = value.splitn(5, ';');
c.family_name = parts.next().unwrap_or("").to_string();
c.given_name = parts.next().unwrap_or("").to_string();
}
// ── tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
const SIMPLE_VCARD: &str = "\
BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
FN:Denis Parfionovich\r\n\
N:Parfionovich;Denis;;;\r\n\
EMAIL;TYPE=INTERNET:denis@example.com\r\n\
ORG:KeiSei Labs\r\n\
NOTE:hand-written note\r\n\
UID:abc-123\r\n\
END:VCARD\r\n";
#[test]
fn parse_simple_vcard() {
let c = parse_vcard(SIMPLE_VCARD).expect("should parse");
assert_eq!(c.display_name, "Denis Parfionovich");
assert_eq!(c.given_name, "Denis");
assert_eq!(c.family_name, "Parfionovich");
assert_eq!(c.emails, vec!["denis@example.com"]);
assert_eq!(c.organization, "KeiSei Labs");
assert_eq!(c.note, "hand-written note");
assert_eq!(c.uid, "abc-123");
}
#[test]
fn parse_multi_email_vcard() {
let text = "\
BEGIN:VCARD\r\n\
VERSION:3.0\r\n\
FN:Alice Smith\r\n\
UID:uid-alice\r\n\
EMAIL;TYPE=INTERNET:alice@work.com\r\n\
EMAIL;TYPE=HOME:alice@home.com\r\n\
TEL;TYPE=CELL:+1234567890\r\n\
END:VCARD\r\n";
let c = parse_vcard(text).expect("should parse");
assert_eq!(c.emails.len(), 2);
assert!(c.emails.contains(&"alice@work.com".to_string()));
assert!(c.emails.contains(&"alice@home.com".to_string()));
assert_eq!(c.phones, vec!["+1234567890"]);
}
#[test]
fn parse_invalid_vcard_returns_error() {
let text = "NOTACARD:yes\r\n";
assert!(parse_vcard(text).is_err());
}
}

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! XML helpers for CardDAV multistatus responses.
use crate::contact::AppleContact;
use crate::error::ContactsError;
use crate::vcard::parse_vcard;
use regex::Regex;
use tracing::debug;
/// Build the CardDAV `addressbook-query` REPORT XML body.
pub(crate) fn addressbook_query_xml() -> String {
r#"<?xml version="1.0" encoding="utf-8"?>
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop><D:getetag/><C:address-data/></D:prop>
<C:filter/>
</C:addressbook-query>"#
.to_string()
}
/// Extract embedded vCards from a CardDAV multistatus XML response.
///
/// Parsing is done with a simple regex to avoid pulling in a full XML crate.
pub(crate) fn extract_contacts_from_multistatus(
xml: &str,
) -> Result<Vec<AppleContact>, ContactsError> {
let re = Regex::new(r"(?si)<[^:>]*:?address-data[^>]*>(.*?)</[^:>]*:?address-data>")
.map_err(|e| ContactsError::Parse(e.to_string()))?;
let mut contacts = Vec::new();
for cap in re.captures_iter(xml) {
let raw = cap.get(1).map(|m| m.as_str()).unwrap_or("").trim();
if raw.is_empty() {
continue;
}
match parse_vcard(raw) {
Ok(c) => contacts.push(c),
Err(e) => {
debug!("skipping unparseable vCard: {e}");
}
}
}
Ok(contacts)
}

View file

@ -0,0 +1,31 @@
[package]
name = "kei-contacts-google"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
description = "Thin client for Google People API. Consumes an access_token from kei-auth-google; does NOT perform OAuth itself."
authors.workspace = true
license.workspace = true
[lib]
name = "kei_contacts_google"
path = "src/lib.rs"
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true, features = ["macros"] }
tracing = "0.1"
kei-social-store = { path = "../kei-social-store" }
[dev-dependencies]
wiremock = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
[package.metadata.keisei]
maturity = "alpha"
backend = "google"
description = "Google People API thin client (token-in, Vec<GoogleContact>-out)"
authors = ["Denis Parfionovich <parfionovich@keilab.io>"]

View file

@ -0,0 +1,35 @@
# kei-contacts-google
Thin client for the [Google People API v1](https://developers.google.com/people).
Consumes a pre-acquired OAuth2 access token (e.g. from `kei-auth-google`).
Does **not** perform OAuth itself.
## Usage
```rust
use kei_contacts_google::GooglePeopleClient;
use kei_social_store::{Store, people::add_person};
async fn sync_contacts(token: String, store: &Store) -> anyhow::Result<()> {
let client = GooglePeopleClient::new(token);
let contacts = client.list_connections().await?;
for c in &contacts {
let person = c.to_person();
add_person(store, &person)?;
}
Ok(())
}
```
## Output shape
Each `GoogleContact` maps to `kei_social_store::Person`:
| Person field | Source |
|----------------|----------------------------------|
| `name` | `display_name` or `given family` |
| `email` | first email address |
| `organization` | first organization name |
| `source` | `"google:{resource_name}"` |
| `bio` | first biography value |

View file

@ -0,0 +1,181 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! [`GooglePeopleClient`] — thin HTTP wrapper around Google People API v1.
use crate::contact::GoogleContact;
use crate::error::ContactsError;
use reqwest::Client;
use serde::Deserialize;
use tracing::debug;
const DEFAULT_BASE_URL: &str = "https://people.googleapis.com";
const PERSON_FIELDS: &str =
"names,emailAddresses,phoneNumbers,organizations,biographies";
const PAGE_SIZE: u32 = 200;
/// Thin client for the Google People API.
///
/// Expects a valid OAuth2 access token. Does NOT perform OAuth itself;
/// obtain the token from `kei-auth-google` or similar.
pub struct GooglePeopleClient {
access_token: String,
base_url: String,
client: Client,
}
impl GooglePeopleClient {
/// Construct a client with the given access token and the production base URL.
pub fn new(access_token: String) -> Self {
Self {
access_token,
base_url: DEFAULT_BASE_URL.to_string(),
client: Client::new(),
}
}
/// Override the base URL (useful for wiremock tests).
pub fn with_base_url(mut self, url: String) -> Self {
self.base_url = url;
self
}
/// Fetch the authenticated user's contacts (first page only, ≤ 200).
///
/// # TODO
/// Pagination via `nextPageToken` is not yet implemented. For users
/// with > 200 contacts only the first 200 are returned.
pub async fn list_connections(&self) -> Result<Vec<GoogleContact>, ContactsError> {
let url = format!(
"{}/v1/people/me/connections?personFields={}&pageSize={}",
self.base_url, PERSON_FIELDS, PAGE_SIZE
);
debug!(%url, "GET people/me/connections");
let resp = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.send()
.await
.map_err(|e| ContactsError::Http(e.to_string()))?;
let status = resp.status();
if status.as_u16() == 401 {
return Err(ContactsError::Auth("token expired or invalid".to_string()));
}
if !status.is_success() {
return Err(ContactsError::Http(format!("status={}", status)));
}
let body: ConnectionsResponse = resp
.json()
.await
.map_err(|e| ContactsError::Parse(e.to_string()))?;
let contacts = body
.connections
.unwrap_or_default()
.into_iter()
.map(parse_connection)
.collect();
Ok(contacts)
}
}
// ── internal deserialization types ───────────────────────────────────────────
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ConnectionsResponse {
connections: Option<Vec<Connection>>,
// next_page_token intentionally ignored (TODO: pagination)
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct Connection {
resource_name: Option<String>,
names: Option<Vec<Name>>,
email_addresses: Option<Vec<EmailAddress>>,
phone_numbers: Option<Vec<PhoneNumber>>,
organizations: Option<Vec<OrgEntry>>,
biographies: Option<Vec<Biography>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Name {
display_name: Option<String>,
given_name: Option<String>,
family_name: Option<String>,
}
#[derive(Deserialize)]
struct EmailAddress {
value: Option<String>,
}
#[derive(Deserialize)]
struct PhoneNumber {
value: Option<String>,
}
#[derive(Deserialize)]
struct OrgEntry {
name: Option<String>,
}
#[derive(Deserialize)]
struct Biography {
value: Option<String>,
}
fn parse_connection(c: Connection) -> GoogleContact {
let resource_name = c.resource_name.unwrap_or_default();
let (display_name, given_name, family_name) = c
.names
.and_then(|mut v| if v.is_empty() { None } else { Some(v.remove(0)) })
.map(|n| (
n.display_name.unwrap_or_default(),
n.given_name.unwrap_or_default(),
n.family_name.unwrap_or_default(),
))
.unwrap_or_default();
let emails = c
.email_addresses
.unwrap_or_default()
.into_iter()
.filter_map(|e| e.value)
.collect();
let phones = c
.phone_numbers
.unwrap_or_default()
.into_iter()
.filter_map(|p| p.value)
.collect();
let organization = c
.organizations
.and_then(|mut v| v.first_mut().and_then(|o| o.name.take()))
.unwrap_or_default();
let bio = c
.biographies
.and_then(|mut v| v.first_mut().and_then(|b| b.value.take()))
.unwrap_or_default();
GoogleContact {
resource_name,
display_name,
given_name,
family_name,
emails,
phones,
organization,
bio,
}
}

View file

@ -0,0 +1,92 @@
// 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");
}
}

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! `ContactsError` — typed error variants for the Google People API client.
use thiserror::Error;
/// Errors produced by [`crate::GooglePeopleClient`].
#[derive(Debug, Error)]
pub enum ContactsError {
/// Network or non-auth HTTP error.
#[error("http error: {0}")]
Http(String),
/// JSON deserialization failure.
#[error("parse error: {0}")]
Parse(String),
/// Google People API returned 401 — token expired or invalid.
#[error("auth error: token expired or invalid")]
Auth(String),
}

View file

@ -0,0 +1,28 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! `kei-contacts-google` — thin client for Google People API v1.
//!
//! Expects an OAuth2 access token already acquired (e.g. from `kei-auth-google`).
//! Does NOT perform OAuth itself.
//!
//! # Quick start
//! ```no_run
//! # async fn example() -> Result<(), kei_contacts_google::ContactsError> {
//! let token = std::env::var("GOOGLE_ACCESS_TOKEN").unwrap();
//! let client = kei_contacts_google::GooglePeopleClient::new(token);
//! let contacts = client.list_connections().await?;
//! for c in &contacts {
//! let person = c.to_person();
//! println!("{} <{}>", person.name, person.email);
//! }
//! # Ok(())
//! # }
//! ```
pub mod client;
pub mod contact;
pub mod error;
pub use client::GooglePeopleClient;
pub use contact::GoogleContact;
pub use error::ContactsError;

View file

@ -0,0 +1,73 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! Integration tests for `GooglePeopleClient` against a wiremock server.
use kei_contacts_google::{ContactsError, GooglePeopleClient};
use wiremock::matchers::{header_exists, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const SAMPLE_RESPONSE: &str = r#"{
"connections": [
{
"resourceName": "people/c111",
"names": [{"displayName": "Alice Smith", "givenName": "Alice", "familyName": "Smith"}],
"emailAddresses": [{"value": "alice@example.com"}],
"phoneNumbers": [{"value": "+1-555-0101"}],
"organizations": [{"name": "ACME"}],
"biographies": [{"value": "Engineer"}]
},
{
"resourceName": "people/c222",
"names": [{"displayName": "Bob Jones", "givenName": "Bob", "familyName": "Jones"}],
"emailAddresses": [{"value": "bob@example.com"}, {"value": "bob2@example.com"}],
"phoneNumbers": [],
"organizations": [],
"biographies": []
}
],
"nextPageToken": "tok123"
}"#;
#[tokio::test]
async fn list_connections_parses_real_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/people/me/connections"))
.and(header_exists("Authorization"))
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_RESPONSE))
.mount(&server)
.await;
let client = GooglePeopleClient::new("fake-token".to_string())
.with_base_url(server.uri());
let contacts = client.list_connections().await.expect("should succeed");
assert_eq!(contacts.len(), 2);
let alice = &contacts[0];
assert_eq!(alice.resource_name, "people/c111");
assert_eq!(alice.display_name, "Alice Smith");
assert_eq!(alice.emails, vec!["alice@example.com"]);
assert_eq!(alice.phones, vec!["+1-555-0101"]);
assert_eq!(alice.organization, "ACME");
assert_eq!(alice.bio, "Engineer");
let bob = &contacts[1];
assert_eq!(bob.emails.len(), 2);
assert_eq!(bob.phones.len(), 0);
assert_eq!(bob.organization, "");
}
#[tokio::test]
async fn auth_error_on_401() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/people/me/connections"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = GooglePeopleClient::new("expired-token".to_string())
.with_base_url(server.uri());
let err = client.list_connections().await.expect_err("should fail");
assert!(matches!(err, ContactsError::Auth(_)));
}