Three atomics finish phase 3 of kei-buddy contacts integration:
## kei-buddy: contact-sync glue + slash commands (+5 tests)
New src/contacts_sync.rs (146 LOC):
* SyncReport { fetched, added, skipped, errors }
* sync_from_google(access_token, contacts) — builds GooglePeopleClient,
list_connections, dedups by (name+email) via search_contacts,
add_contact in loop
* sync_from_apple(apple_id, app_pw, addressbook_url, contacts) — same
pattern over ICloudCardDavClient.list_contacts
* All errors collected into report.errors; never panics, never propagates
New slash commands in commands.rs / command_exec.rs:
* /sync-google — reads GOOGLE_OAUTH_ACCESS_TOKEN env, calls sync_from_google,
Russian-formatted summary "Google: загружено N, добавлено M, пропущено K"
* /sync-apple — reads APPLE_ID + APPLE_APP_PASSWORD + APPLE_CARDDAV_URL,
calls sync_from_apple
* Missing env → human-readable "не настроено: …" response
* /help text updated
Deps added: kei-contacts-google + kei-contacts-apple as path deps.
## kei-contacts-google: pagination via nextPageToken (+1 test)
Refactor: client.rs 182→56 LOC; pagination logic + deserialization moved
to new src/pagination.rs (188 LOC). list_connections unchanged
(back-compat, returns first page only). New list_all_connections loops
via fetch_page(Some(token)) until token=None; hard cap 50 pages with
tracing::warn on cap.
Test list_all_connections_two_pages: wiremock returns page 1 with
nextPageToken="abc" + page 2 without; assert len = sum AND second
request carries pageToken=abc query.
## kei-contacts-apple: vCard line-folding + CardDAV auto-discovery (+2 tests)
vcard.rs +unfold() helper applied in parse_vcard per RFC 6350 §3.2:
continuation lines starting with space/tab strip the prefix and append
to previous line. Test parse_folded_vcard.
New src/discovery.rs (199 LOC): discover_addressbook() walks
.well-known/carddav → current-user-principal → addressbook-home-set →
first addressbook with C:addressbook resourcetype. Three PROPFIND
requests with canned XML bodies. Regex-based extract_first_href_under +
extract_addressbook_href helpers. Test discover_walks_three_propfinds
against 3-step wiremock fixture.
client.rs adds discover_addressbook_url() method calling discovery.
## Verify-before-commit
* cargo check --workspace: PASS
* cargo test -p kei-buddy --lib: 46/0 (was 41)
* cargo test -p kei-contacts-google: 5/0 (was 4, +1 pagination)
* cargo test -p kei-contacts-apple: 9/0 (was 7, +1 folding +1 discovery)
NOT deployed — user still in live conversation with bot.
Follow-up (deferred, non-blocking):
* Real iCloud smoke test for discover_addressbook_url — regex parser
may need adjustment for deeply-nested namespace prefixes
* Wiremock-backed integration test for sync_from_google glue (HTTP
layer already covered in kei-contacts-google tests)
188 lines
4.9 KiB
Rust
188 lines
4.9 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 <author org>
|
|
//! 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<Vec<Connection>>,
|
|
pub next_page_token: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct Connection {
|
|
pub resource_name: Option<String>,
|
|
pub names: Option<Vec<Name>>,
|
|
pub email_addresses: Option<Vec<EmailAddress>>,
|
|
pub phone_numbers: Option<Vec<PhoneNumber>>,
|
|
pub organizations: Option<Vec<OrgEntry>>,
|
|
pub biographies: Option<Vec<Biography>>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct Name {
|
|
pub display_name: Option<String>,
|
|
pub given_name: Option<String>,
|
|
pub family_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct EmailAddress {
|
|
pub value: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct PhoneNumber {
|
|
pub value: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct OrgEntry {
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct Biography {
|
|
pub value: Option<String>,
|
|
}
|
|
|
|
/// 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<GoogleContact>, Option<String>), 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<Vec<GoogleContact>, ContactsError> {
|
|
let mut all: Vec<GoogleContact> = Vec::new();
|
|
let mut next_token: Option<String> = 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,
|
|
}
|
|
}
|
|
|