diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 0cb8235..68c987a 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -3195,6 +3195,8 @@ dependencies = [ "chrono", "clap", "kei-chat-store", + "kei-contacts-apple", + "kei-contacts-google", "kei-memory-sqlite", "kei-sage", "kei-social-store", diff --git a/_primitives/_rust/kei-buddy/Cargo.toml b/_primitives/_rust/kei-buddy/Cargo.toml index 0b182c6..52ff76f 100644 --- a/_primitives/_rust/kei-buddy/Cargo.toml +++ b/_primitives/_rust/kei-buddy/Cargo.toml @@ -34,6 +34,8 @@ kei-memory-sqlite = { path = "../kei-memory-sqlite" } kei-chat-store = { path = "../kei-chat-store" } kei-social-store = { path = "../kei-social-store" } kei-sage = { path = "../kei-sage" } +kei-contacts-google = { path = "../kei-contacts-google" } +kei-contacts-apple = { path = "../kei-contacts-apple" } chrono = { workspace = true } # serve feature deps diff --git a/_primitives/_rust/kei-buddy/src/command_exec.rs b/_primitives/_rust/kei-buddy/src/command_exec.rs index 8dd514e..45557c9 100644 --- a/_primitives/_rust/kei-buddy/src/command_exec.rs +++ b/_primitives/_rust/kei-buddy/src/command_exec.rs @@ -2,7 +2,14 @@ //! 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}; +use std::sync::Arc; + +use crate::{ + chat_log::ChatLog, + contacts::Contacts, + contacts_sync::{sync_from_apple, sync_from_google}, + topics::Topics, +}; pub(crate) async fn exec_topics(chat_id: i64, topics: &Topics) -> String { match topics.list_topics(chat_id).await { @@ -109,3 +116,30 @@ fn truncate(s: &str, max_chars: usize) -> &str { Some((idx, _)) => &s[..idx], } } + +pub(crate) async fn exec_sync_google(contacts: &Arc) -> String { + let token = match std::env::var("GOOGLE_OAUTH_ACCESS_TOKEN") { + Ok(t) if !t.is_empty() => t, + _ => return "не настроено: GOOGLE_OAUTH_ACCESS_TOKEN не задан".to_string(), + }; + let r = sync_from_google(&token, contacts).await; + format!( + "Google: загружено {}, добавлено {}, пропущено {}\nошибок: {}", + r.fetched, r.added, r.skipped, r.errors.len() + ) +} + +pub(crate) async fn exec_sync_apple(contacts: &Arc) -> String { + let apple_id = std::env::var("APPLE_ID").unwrap_or_default(); + let app_pw = std::env::var("APPLE_APP_PASSWORD").unwrap_or_default(); + let url = std::env::var("APPLE_CARDDAV_URL").unwrap_or_default(); + if apple_id.is_empty() || app_pw.is_empty() || url.is_empty() { + return "не настроено: APPLE_ID / APPLE_APP_PASSWORD / APPLE_CARDDAV_URL не заданы" + .to_string(); + } + let r = sync_from_apple(&apple_id, &app_pw, &url, contacts).await; + format!( + "Apple: загружено {}, добавлено {}, пропущено {}\nошибок: {}", + r.fetched, r.added, r.skipped, r.errors.len() + ) +} diff --git a/_primitives/_rust/kei-buddy/src/commands.rs b/_primitives/_rust/kei-buddy/src/commands.rs index f1c8800..17b2865 100644 --- a/_primitives/_rust/kei-buddy/src/commands.rs +++ b/_primitives/_rust/kei-buddy/src/commands.rs @@ -5,7 +5,12 @@ use std::sync::Arc; -use crate::{chat_log::ChatLog, command_exec as exec, contacts::Contacts, topics::Topics}; +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> { @@ -14,6 +19,8 @@ pub enum Command<'a> { Topics, Contacts, Help, + SyncGoogle, + SyncApple, } /// Shared store references passed to `execute_command`. @@ -28,6 +35,8 @@ const HELP_TEXT: &str = "Доступные команды:\n\ /find <текст> — поиск по переписке\n\ /topics — список тем\n\ /contacts — список контактов\n\ + /sync-google — синхронизировать контакты Google (нужен GOOGLE_OAUTH_ACCESS_TOKEN)\n\ + /sync-apple — синхронизировать контакты Apple (нужны APPLE_ID / APPLE_APP_PASSWORD / APPLE_CARDDAV_URL)\n\ /help — это сообщение"; /// Parse a raw user text into a Command, or None if it is not a slash-command. @@ -53,6 +62,12 @@ pub fn parse_command(text: &str) -> Option> { if lower.starts_with("find") { return Some(Command::Find(rest[4..].trim())); } + if lower.eq("sync-google") { + return Some(Command::SyncGoogle); + } + if lower.eq("sync-apple") { + return Some(Command::SyncApple); + } None } @@ -69,6 +84,8 @@ pub async fn execute_command( 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, + Command::SyncGoogle => exec::exec_sync_google(stores.contacts).await, + Command::SyncApple => exec::exec_sync_apple(stores.contacts).await, } } @@ -143,4 +160,25 @@ mod tests { let resp = execute_command(Command::Contacts, 1, &stores).await; assert!(resp.contains("пусты") || resp.contains("контакт")); } + + #[test] + fn parse_sync_google() { + assert!(matches!(parse_command("/sync-google"), Some(Command::SyncGoogle))); + } + + #[test] + fn parse_sync_apple() { + assert!(matches!(parse_command("/sync-apple"), Some(Command::SyncApple))); + } + + #[tokio::test] + async fn help_includes_sync_commands() { + 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("/sync-google")); + assert!(resp.contains("/sync-apple")); + } } diff --git a/_primitives/_rust/kei-buddy/src/contacts_sync.rs b/_primitives/_rust/kei-buddy/src/contacts_sync.rs new file mode 100644 index 0000000..925cdf0 --- /dev/null +++ b/_primitives/_rust/kei-buddy/src/contacts_sync.rs @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Contact-sync helpers — pull Google / Apple contacts into local store. +//! Each function is fire-and-forget: errors are collected in `SyncReport`. + +use std::sync::Arc; + +use kei_contacts_apple::ICloudCardDavClient; +use kei_contacts_google::GooglePeopleClient; + +use crate::contacts::Contacts; + +/// Summary returned by a sync operation regardless of partial failures. +#[derive(Debug, Default)] +pub struct SyncReport { + /// Total contacts returned by the remote source. + pub fetched: usize, + /// Contacts successfully written to the local store. + pub added: usize, + /// Contacts skipped (empty name+email, or duplicate by name+email). + pub skipped: usize, + /// Error strings accumulated during sync; non-fatal individually. + pub errors: Vec, +} + +/// Pull contacts from Google People API into `contacts`. +/// +/// Requires a valid OAuth2 access token (not obtained here). +/// Never panics; all errors collected in [`SyncReport::errors`]. +pub async fn sync_from_google( + access_token: &str, + contacts: &Arc, +) -> SyncReport { + let client = GooglePeopleClient::new(access_token.to_string()); + let all = match client.list_connections().await { + Ok(v) => v, + Err(e) => { + return SyncReport { + errors: vec![format!("google list_connections: {e}")], + ..Default::default() + }; + } + }; + let fetched = all.len(); + let mut report = SyncReport { fetched, ..Default::default() }; + for contact in all { + process_person(contact.to_person(), contacts, &mut report).await; + } + report +} + +/// Pull contacts from iCloud CardDAV into `contacts`. +/// +/// `addressbook_url` must be the full CardDAV addressbook URL. +/// Never panics; all errors collected in [`SyncReport::errors`]. +pub async fn sync_from_apple( + apple_id: &str, + app_password: &str, + addressbook_url: &str, + contacts: &Arc, +) -> SyncReport { + let client = ICloudCardDavClient::new(apple_id.to_string(), app_password.to_string()) + .with_addressbook_url(addressbook_url.to_string()); + let all = match client.list_contacts().await { + Ok(v) => v, + Err(e) => { + return SyncReport { + errors: vec![format!("apple list_contacts: {e}")], + ..Default::default() + }; + } + }; + let fetched = all.len(); + let mut report = SyncReport { fetched, ..Default::default() }; + for contact in all { + process_person(contact.to_person(), contacts, &mut report).await; + } + report +} + +/// Shared dedup + insert logic for a single Person. +async fn process_person( + person: kei_social_store::people::Person, + contacts: &Arc, + report: &mut SyncReport, +) { + if person.name.is_empty() && person.email.is_empty() { + report.skipped += 1; + return; + } + if is_duplicate(&person, contacts).await { + report.skipped += 1; + return; + } + match contacts.add_contact(person).await { + Ok(_) => report.added += 1, + Err(e) => report.errors.push(format!("add_contact: {e}")), + } +} + +/// Returns `true` when `contacts` already has an entry with the same +/// case-insensitive name AND case-insensitive email (both non-empty). +async fn is_duplicate( + person: &kei_social_store::people::Person, + contacts: &Arc, +) -> bool { + if person.name.is_empty() || person.email.is_empty() { + return false; + } + let hits = match contacts.search_contacts(&person.name, 3).await { + Ok(v) => v, + Err(_) => return false, + }; + let name_lc = person.name.to_lowercase(); + let email_lc = person.email.to_lowercase(); + hits.iter().any(|h| { + h.name.to_lowercase() == name_lc && h.email.to_lowercase() == email_lc + }) +} + +// ── tests ───────────────────────────────────────────────────────────────────── +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn report_initial_zero() { + let r = SyncReport::default(); + assert_eq!(r.fetched, 0); + assert_eq!(r.added, 0); + assert_eq!(r.skipped, 0); + assert!(r.errors.is_empty()); + } + + #[tokio::test] + async fn sync_google_bad_token_populates_errors() { + // Using an obviously-invalid token; no real network required because + // reqwest will return a connection error in the sandbox environment, + // but we verify the SyncReport shape on any error path. + let contacts = Arc::new(Contacts::from_memory().unwrap()); + let report = sync_from_google("invalid-token", &contacts).await; + // fetched == 0 and either an error was collected OR the network + // returned something parseable (both are valid non-panic outcomes). + assert_eq!(report.fetched, 0); + assert_eq!(report.added, 0); + } +} diff --git a/_primitives/_rust/kei-buddy/src/lib.rs b/_primitives/_rust/kei-buddy/src/lib.rs index 4e1ef2c..7a972b2 100644 --- a/_primitives/_rust/kei-buddy/src/lib.rs +++ b/_primitives/_rust/kei-buddy/src/lib.rs @@ -14,6 +14,7 @@ pub mod chat_log; pub(crate) mod command_exec; pub mod commands; pub mod contacts; +pub mod contacts_sync; pub mod error; pub mod extractor; pub mod machine; @@ -37,6 +38,7 @@ pub mod serve_telegram; pub use chat_log::ChatLog; pub use commands::{parse_command, execute_command, Command, CommandStores}; +pub use contacts_sync::{sync_from_apple, sync_from_google, SyncReport}; pub use contacts::Contacts; pub use error::BuddyError; pub use extractor::LlmExtractor; diff --git a/_primitives/_rust/kei-contacts-apple/src/client.rs b/_primitives/_rust/kei-contacts-apple/src/client.rs index ba3a3d8..7b2bf83 100644 --- a/_primitives/_rust/kei-contacts-apple/src/client.rs +++ b/_primitives/_rust/kei-contacts-apple/src/client.rs @@ -3,6 +3,7 @@ //! [`ICloudCardDavClient`] — CardDAV client for iCloud Contacts. use crate::contact::AppleContact; +use crate::discovery::discover_addressbook; use crate::error::ContactsError; use crate::xml::{addressbook_query_xml, extract_contacts_from_multistatus}; use reqwest::{Client, Method}; @@ -55,6 +56,22 @@ impl ICloudCardDavClient { self } + /// Discover the addressbook URL via three successive PROPFIND requests. + /// + /// Implements RFC 6764 §6: + /// 1. `.well-known/carddav` → principal URL + /// 2. principal → addressbook-home-set + /// 3. home-set (depth=1) → first addressbook resource href + pub async fn discover_addressbook_url(&self) -> Result { + discover_addressbook( + &self.client, + &self.apple_id, + &self.app_specific_password, + &self.base_url, + ) + .await + } + /// Fetch all contacts from the configured addressbook. /// /// Issues a CardDAV REPORT `addressbook-query` and returns parsed contacts. diff --git a/_primitives/_rust/kei-contacts-apple/src/discovery.rs b/_primitives/_rust/kei-contacts-apple/src/discovery.rs new file mode 100644 index 0000000..def62ef --- /dev/null +++ b/_primitives/_rust/kei-contacts-apple/src/discovery.rs @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 +//! CardDAV auto-discovery via three successive PROPFIND requests. +//! +//! Implements RFC 6764 §6 well-known URI discovery. + +use crate::error::ContactsError; +use regex::Regex; +use reqwest::{Client, Method}; +use tracing::debug; + +// ── XML bodies ──────────────────────────────────────────────────────────────── + +fn propfind_principal_xml() -> &'static str { + r#" + + +"# +} + +fn propfind_home_set_xml() -> &'static str { + r#" + + +"# +} + +fn propfind_resourcetype_xml() -> &'static str { + r#" + + +"# +} + +// ── XML helpers ─────────────────────────────────────────────────────────────── + +/// Extract the first `` child of `tag` using regex. +/// +/// Matches namespace-prefixed variants of `tag` (e.g. `D:current-user-principal` +/// or `C:addressbook-home-set`). +pub(crate) fn extract_first_href_under(xml: &str, tag: &str) -> Option { + let pattern = format!( + r"(?si)<(?:[a-zA-Z0-9_-]+:)?{tag}[^>]*>\s*<(?:[a-zA-Z0-9_-]+:)?href[^>]*>([^<]+)` from a multistatus `` whose +/// `` contains `addressbook`. +pub(crate) fn extract_addressbook_href(xml: &str) -> Option { + // Split on or boundaries (case-insensitive). + let re_split = Regex::new(r"(?si)<(?:[a-zA-Z0-9_-]+:)?response[^>]*>").ok()?; + let boundaries: Vec<_> = re_split.find_iter(xml).map(|m| m.start()).collect(); + + for (i, &start) in boundaries.iter().enumerate() { + let end = boundaries.get(i + 1).copied().unwrap_or(xml.len()); + let chunk = &xml[start..end]; + + if chunk.to_ascii_lowercase().contains("addressbook") { + // Extract the href from this response chunk. + let re_href = + Regex::new(r"(?si)<(?:[a-zA-Z0-9_-]+:)?href[^>]*>([^<]+) Result { + debug!(%url, %depth, "PROPFIND"); + let resp = client + .request( + Method::from_bytes(b"PROPFIND") + .map_err(|e| ContactsError::Http(e.to_string()))?, + url, + ) + .basic_auth(apple_id, Some(password)) + .header("Content-Type", "application/xml; charset=utf-8") + .header("Depth", depth) + .body(body) + .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!("PROPFIND status={}", status))); + } + resp.text() + .await + .map_err(|e| ContactsError::Parse(e.to_string())) +} + +// ── public entry point ──────────────────────────────────────────────────────── + +// ── tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::Client; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn discover_walks_three_propfinds() { + let server = MockServer::start().await; + + Mock::given(method("PROPFIND")) + .and(path("/.well-known/carddav")) + .respond_with(ResponseTemplate::new(207).set_body_string( + r#" + /principals/users/testuser/ +"#, + )) + .mount(&server) + .await; + + Mock::given(method("PROPFIND")) + .and(path("/principals/users/testuser/")) + .respond_with(ResponseTemplate::new(207).set_body_string( + r#" + /addressbooks/testuser/ +"#, + )) + .mount(&server) + .await; + + Mock::given(method("PROPFIND")) + .and(path("/addressbooks/testuser/")) + .respond_with(ResponseTemplate::new(207).set_body_string( + r#" + + /addressbooks/testuser/card/ + + +"#, + )) + .mount(&server) + .await; + + let client = Client::new(); + let url = discover_addressbook(&client, "user@icloud.com", "app-pass", &server.uri()) + .await + .expect("discovery should succeed"); + assert_eq!(url, "/addressbooks/testuser/card/"); + } +} + +/// Perform CardDAV three-step auto-discovery. +/// +/// 1. PROPFIND `/.well-known/carddav` → `current-user-principal` +/// 2. PROPFIND `{principal}` → `addressbook-home-set` +/// 3. PROPFIND `{home-set}` (depth=1) → first `addressbook` resource href +pub(crate) async fn discover_addressbook( + client: &Client, + apple_id: &str, + password: &str, + base_url: &str, +) -> Result { + // Step 1: principal + let url1 = format!("{}/.well-known/carddav", base_url); + let xml1 = propfind(client, apple_id, password, &url1, "0", propfind_principal_xml()).await?; + let principal = extract_first_href_under(&xml1, "current-user-principal") + .ok_or_else(|| ContactsError::Parse("discover step 1: no current-user-principal".into()))?; + + // Step 2: home set + let url2 = format!("{}{}", base_url, principal); + let xml2 = propfind(client, apple_id, password, &url2, "0", propfind_home_set_xml()).await?; + let home_set = extract_first_href_under(&xml2, "addressbook-home-set") + .ok_or_else(|| ContactsError::Parse("discover step 2: no addressbook-home-set".into()))?; + + // Step 3: addressbook resource + let url3 = format!("{}{}", base_url, home_set); + let xml3 = + propfind(client, apple_id, password, &url3, "1", propfind_resourcetype_xml()).await?; + extract_addressbook_href(&xml3) + .ok_or_else(|| ContactsError::Parse("discover step 3: no addressbook resource".into())) +} diff --git a/_primitives/_rust/kei-contacts-apple/src/lib.rs b/_primitives/_rust/kei-contacts-apple/src/lib.rs index 2b6be64..c462d53 100644 --- a/_primitives/_rust/kei-contacts-apple/src/lib.rs +++ b/_primitives/_rust/kei-contacts-apple/src/lib.rs @@ -28,6 +28,7 @@ pub mod client; pub mod contact; pub mod error; pub mod vcard; +pub(crate) mod discovery; pub(crate) mod xml; pub use client::ICloudCardDavClient; diff --git a/_primitives/_rust/kei-contacts-apple/src/vcard.rs b/_primitives/_rust/kei-contacts-apple/src/vcard.rs index 93d8ce4..8da96f9 100644 --- a/_primitives/_rust/kei-contacts-apple/src/vcard.rs +++ b/_primitives/_rust/kei-contacts-apple/src/vcard.rs @@ -3,9 +3,6 @@ //! 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. @@ -13,22 +10,39 @@ use crate::contact::AppleContact; use crate::error::ContactsError; +/// Unfold RFC 6350 §3.2 continuation lines. +/// +/// A line beginning with a single SPACE or HTAB is a continuation of the +/// preceding line; strip the leading whitespace and concatenate. +fn unfold(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for line in input.lines() { + if let Some(rest) = line.strip_prefix(' ').or_else(|| line.strip_prefix('\t')) { + // continuation — append directly to previous content + out.push_str(rest); + } else { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(line); + } + } + out +} + /// Parse a single vCard text into an [`AppleContact`]. /// /// `text` must be the content of one vCard (between `BEGIN:VCARD` and `END:VCARD` -/// inclusive). +/// inclusive). RFC 6350 line-folding is resolved before parsing. pub fn parse_vcard(text: &str) -> Result { + let unfolded = unfold(text); let mut contact = AppleContact { raw_vcard: text.to_string(), ..Default::default() }; - for line in text.lines() { + for line in unfolded.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; }; @@ -137,4 +151,24 @@ END:VCARD\r\n"; let text = "NOTACARD:yes\r\n"; assert!(parse_vcard(text).is_err()); } + + #[test] + fn parse_folded_vcard() { + // RFC 6350 §3.2 fold: continuation lines start with a single SPACE. + // NOTE spans three physical lines; after unfold they join into one value. + // Use concat! to guarantee the leading spaces are preserved. + let text = concat!( + "BEGIN:VCARD\r\n", + "VERSION:3.0\r\n", + "FN:Alice Smith\r\n", + "UID:uid-folded\r\n", + "NOTE:line one\r\n", + " line two\r\n", + " line three\r\n", + "END:VCARD\r\n", + ); + let c = parse_vcard(text).expect("should parse folded vCard"); + assert_eq!(c.display_name, "Alice Smith"); + assert_eq!(c.note, "line oneline twoline three"); + } } diff --git a/_primitives/_rust/kei-contacts-google/src/client.rs b/_primitives/_rust/kei-contacts-google/src/client.rs index ce032d6..fcfb9c1 100644 --- a/_primitives/_rust/kei-contacts-google/src/client.rs +++ b/_primitives/_rust/kei-contacts-google/src/client.rs @@ -4,14 +4,10 @@ use crate::contact::GoogleContact; use crate::error::ContactsError; +use crate::pagination::{fetch_all_pages, fetch_page}; 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. /// @@ -39,143 +35,22 @@ impl GooglePeopleClient { self } - /// Fetch the authenticated user's contacts (first page only, ≤ 200). + /// Fetch the first page of contacts (≤ 200). /// - /// # TODO - /// Pagination via `nextPageToken` is not yet implemented. For users - /// with > 200 contacts only the first 200 are returned. + /// Back-compat API — use [`list_all_connections`] for full pagination. + /// + /// [`list_all_connections`]: GooglePeopleClient::list_all_connections pub async fn list_connections(&self) -> Result, 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(); - + let (contacts, _) = + fetch_page(&self.client, &self.access_token, &self.base_url, None).await?; Ok(contacts) } -} -// ── internal deserialization types ─────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ConnectionsResponse { - connections: Option>, - // next_page_token intentionally ignored (TODO: pagination) -} - -#[derive(Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct Connection { - resource_name: Option, - names: Option>, - email_addresses: Option>, - phone_numbers: Option>, - organizations: Option>, - biographies: Option>, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Name { - display_name: Option, - given_name: Option, - family_name: Option, -} - -#[derive(Deserialize)] -struct EmailAddress { - value: Option, -} - -#[derive(Deserialize)] -struct PhoneNumber { - value: Option, -} - -#[derive(Deserialize)] -struct OrgEntry { - name: Option, -} - -#[derive(Deserialize)] -struct Biography { - value: Option, -} - -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, + /// Fetch ALL contacts across all pages. + /// + /// Loops on `nextPageToken` until none is returned. Hard cap at 50 pages + /// (~10 000 contacts) — if hit, returns what was collected and logs a warning. + pub async fn list_all_connections(&self) -> Result, ContactsError> { + fetch_all_pages(&self.client, &self.access_token, &self.base_url).await } } diff --git a/_primitives/_rust/kei-contacts-google/src/lib.rs b/_primitives/_rust/kei-contacts-google/src/lib.rs index d8c48ed..fbd82a8 100644 --- a/_primitives/_rust/kei-contacts-google/src/lib.rs +++ b/_primitives/_rust/kei-contacts-google/src/lib.rs @@ -22,6 +22,7 @@ pub mod client; pub mod contact; pub mod error; +pub(crate) mod pagination; pub use client::GooglePeopleClient; pub use contact::GoogleContact; diff --git a/_primitives/_rust/kei-contacts-google/src/pagination.rs b/_primitives/_rust/kei-contacts-google/src/pagination.rs new file mode 100644 index 0000000..94297d2 --- /dev/null +++ b/_primitives/_rust/kei-contacts-google/src/pagination.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 +//! Pagination helper for Google People API connections. + +use crate::contact::GoogleContact; +use crate::error::ContactsError; +use reqwest::Client; +use serde::Deserialize; +use tracing::{debug, warn}; + +const PERSON_FIELDS: &str = "names,emailAddresses,phoneNumbers,organizations,biographies"; +const PAGE_SIZE: u32 = 200; +/// Safety cap: at most 50 pages (10 000 contacts). +const MAX_PAGES: usize = 50; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConnectionsResponse { + pub connections: Option>, + pub next_page_token: Option, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Connection { + pub resource_name: Option, + pub names: Option>, + pub email_addresses: Option>, + pub phone_numbers: Option>, + pub organizations: Option>, + pub biographies: Option>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Name { + pub display_name: Option, + pub given_name: Option, + pub family_name: Option, +} + +#[derive(Deserialize)] +pub(crate) struct EmailAddress { + pub value: Option, +} + +#[derive(Deserialize)] +pub(crate) struct PhoneNumber { + pub value: Option, +} + +#[derive(Deserialize)] +pub(crate) struct OrgEntry { + pub name: Option, +} + +#[derive(Deserialize)] +pub(crate) struct Biography { + pub value: Option, +} + +/// Fetch one page of connections. +/// +/// Returns `(contacts, next_page_token)`. +pub(crate) async fn fetch_page( + client: &Client, + access_token: &str, + base_url: &str, + page_token: Option<&str>, +) -> Result<(Vec, Option), ContactsError> { + let mut url = format!( + "{}/v1/people/me/connections?personFields={}&pageSize={}", + base_url, PERSON_FIELDS, PAGE_SIZE + ); + if let Some(tok) = page_token { + url.push_str(&format!("&pageToken={}", tok)); + } + debug!(%url, "GET people/me/connections"); + + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {}", 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, body.next_page_token)) +} + +/// Fetch ALL pages accumulating contacts, stopping after [`MAX_PAGES`]. +pub(crate) async fn fetch_all_pages( + client: &Client, + access_token: &str, + base_url: &str, +) -> Result, ContactsError> { + let mut all: Vec = Vec::new(); + let mut next_token: Option = None; + + for page in 0..MAX_PAGES { + let (contacts, token) = + fetch_page(client, access_token, base_url, next_token.as_deref()).await?; + all.extend(contacts); + next_token = token; + if next_token.is_none() { + break; + } + if page == MAX_PAGES - 1 { + warn!( + "hit {MAX_PAGES}-page safety cap; returning {} contacts so far", + all.len() + ); + } + } + + Ok(all) +} + +pub(crate) 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, + } +} + diff --git a/_primitives/_rust/kei-contacts-google/tests/connections.rs b/_primitives/_rust/kei-contacts-google/tests/connections.rs index b929826..bba6091 100644 --- a/_primitives/_rust/kei-contacts-google/tests/connections.rs +++ b/_primitives/_rust/kei-contacts-google/tests/connections.rs @@ -3,7 +3,7 @@ //! Integration tests for `GooglePeopleClient` against a wiremock server. use kei_contacts_google::{ContactsError, GooglePeopleClient}; -use wiremock::matchers::{header_exists, method, path}; +use wiremock::matchers::{header_exists, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; const SAMPLE_RESPONSE: &str = r#"{ @@ -71,3 +71,48 @@ async fn auth_error_on_401() { let err = client.list_connections().await.expect_err("should fail"); assert!(matches!(err, ContactsError::Auth(_))); } + +const PAGE1: &str = r#"{"connections":[ + {"resourceName":"people/c1", + "names":[{"displayName":"Alice","givenName":"Alice","familyName":"Smith"}], + "emailAddresses":[{"value":"alice@example.com"}], + "phoneNumbers":[],"organizations":[],"biographies":[]} +],"nextPageToken":"abc"}"#; + +const PAGE2: &str = r#"{"connections":[ + {"resourceName":"people/c2", + "names":[{"displayName":"Bob","givenName":"Bob","familyName":"Jones"}], + "emailAddresses":[{"value":"bob@example.com"}], + "phoneNumbers":[],"organizations":[],"biographies":[]} +]}"#; + +#[tokio::test] +async fn list_all_connections_two_pages() { + let server = MockServer::start().await; + + // First request: no pageToken — returns page 1 + nextPageToken="abc" + Mock::given(method("GET")) + .and(path("/v1/people/me/connections")) + .respond_with(ResponseTemplate::new(200).set_body_string(PAGE1)) + .up_to_n_times(1) + .mount(&server) + .await; + + // Second request: must carry pageToken=abc — returns page 2 (no token) + Mock::given(method("GET")) + .and(path("/v1/people/me/connections")) + .and(query_param("pageToken", "abc")) + .respond_with(ResponseTemplate::new(200).set_body_string(PAGE2)) + .mount(&server) + .await; + + let client = GooglePeopleClient::new("fake-token".to_string()) + .with_base_url(server.uri()); + + let contacts = client.list_all_connections().await.expect("should succeed"); + + assert_eq!(contacts.len(), 2, "must collect both pages"); + let names: Vec<_> = contacts.iter().map(|c| c.display_name.as_str()).collect(); + assert!(names.contains(&"Alice"), "page 1 contact present"); + assert!(names.contains(&"Bob"), "page 2 contact present"); +}