KeiSeiKit-1.0/_primitives/_rust/kei-contacts-apple
Parfii-bot 06bcce9981 feat(contacts): glue sync + Google pagination + Apple discovery & folding
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)
2026-05-12 17:04:15 +08:00
..
src feat(contacts): glue sync + Google pagination + Apple discovery & folding 2026-05-12 17:04:15 +08:00
Cargo.toml feat(kei-buddy fleet): 5 atomics — google/apple contacts + classifier + tick + slash-commands 2026-05-12 16:33:58 +08:00
README.md feat(kei-buddy fleet): 5 atomics — google/apple contacts + classifier + tick + slash-commands 2026-05-12 16:33:58 +08:00

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

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.