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)
Two parallel atoms in one commit. Both reuse existing KeiSeiKit
primitives (zero new crates) per RULE feedback_inventory_before_decompose.
## src/contacts.rs (200 LOC, +4 tests)
Adapter over kei-social-store. Address book + interaction log + relationship
graph for shared connections.
API:
* Contacts::from_path / from_memory
* add_contact / get_contact / search_contacts
* log_meet(person_id, target_id, channel, note) / interactions_for
* relationship_graph — returns Vec<Pair>, the kei-social-store output
* common_connections(a, b) — post-filters relationship_graph to find
target_ids that appear in pairs with BOTH a and b. This is the
"у нас с Денисом общий друг X" feature.
Pattern: Arc<Mutex<kei_social_store::Store>> + tokio::spawn_blocking,
mirroring chat_log.rs. Errors map to BuddyError::Memory.
Tests: add_and_get_contact_roundtrip / search_contacts_finds_by_name /
log_meet_and_list_interactions / common_connections_finds_shared_target.
## src/topics.rs (200 LOC, +4 tests)
Adapter over kei-sage. Topics + digest notes + FTS5 search. Each topic
is a sage Unit{unit_type="buddy_topic", category="kei-buddy",
source_path="kei-buddy/chat-{chat_id}/topic/{slug}"}. Digests are
Unit{unit_type="buddy_digest"} linked via add_edge(topic→digest,
edge_type="digest_for").
API:
* Topics::from_path / from_memory
* add_topic(chat_id, slug, title, content) — idempotent via path lookup
* add_digest(chat_id, topic_slug, timestamp, content) — creates Unit +
edge
* search(query, limit) — fts_search over all kei-buddy units
* digests_for(chat_id, topic_slug) — follows outgoing edges
* list_topics(chat_id) — raw SELECT scoped by source_path LIKE prefix
Tests: add_topic_then_search_finds_it / add_topic_is_idempotent /
add_digest_creates_edge_and_dest / list_topics_scopes_per_chat.
## Dependencies added
kei-social-store + kei-sage as local path deps. Both already in workspace,
no new external crates.
## Verify-before-commit
* cargo check -p kei-buddy: PASS
* cargo test -p kei-buddy --lib: 31/0 (was 23, +4 contacts +4 topics)
Net change: 4 files touched, ~400 LOC added across the two adapters.
NOT deployed. User still in active bot conversation.
After-Ready conversation was going to /dev/null. With this change every
inbound Telegram text + every bot response is persisted to a SQLite +
FTS5 archive via the existing kei-chat-store primitive (no new crate).
Each Telegram chat_id maps 1:1 to a kei-chat-store session
(project="kei-buddy", title="tg-<chat_id>", model="telegram"). Cache
prevents per-message session lookups.
New file:
* src/chat_log.rs (198 LOC) — ChatLog adapter wrapping
kei_chat_store::Store + a chat_id→session_id Mutex cache.
API: from_path / from_memory / ensure_session / log_user /
log_bot / search(query, chat_id?, limit). Errors map to
BuddyError::Memory and never propagate from on_event — chat-log
failure is logged but does not block the conversation.
Modified:
* Cargo.toml — kei-chat-store path dep added.
* src/lib.rs — pub mod chat_log + re-export ChatLog.
* src/serve.rs — BuddyContext gains Arc<ChatLog>;
process_text calls log_user before handle_step + log_bot after
send_message; ServeConfig gains chat_log_db_path.
* src/bin/kei-buddy.rs — KEI_BUDDY_CHAT_LOG_PATH env
(default ./kei-buddy-chat.db); migrate subcommand applies the
chat-store schema alongside buddy_state schema.
Tests (3 new in src/chat_log.rs, all pass):
* log_user_creates_session_and_message
* log_bot_uses_same_session_as_log_user
* different_chats_get_different_sessions
Verify-before-commit:
* cargo check -p kei-buddy (default): PASS
* cargo check -p kei-buddy --features extractor-openai: PASS
* cargo test -p kei-buddy --lib: 23 passed / 0 failed
(was 20 before this commit; 3 new ChatLog tests)
NOT deployed — user is in active conversation with the live bot.
Will roll forward when user signals readiness.
Two additions on top of the MVP serve binary:
1. Whitelist by chat_id (KEI_BUDDY_ALLOWED_CHAT_IDS env, CSV).
* BuddyContext gains Arc<Option<Vec<i64>>> allowed_chat_ids
* chat_allowed() check fires before process_text
* Non-whitelisted chats: warn-log + ignore (no response sent)
* None or empty list = accept all (back-compat with prior behaviour)
2. Real LLM wiring (KEI_BUDDY_LLM_PROXY / _LLM_KEY / _LLM_MODEL).
* When extractor-openai feature compiled in AND both proxy+key set,
run_serve instantiates OpenAiExtractor instead of MockExtractor
* Defaults: proxy=https://api.openai.com, key=OPENAI_API_KEY env,
model=gpt-4o-mini
* Fallback: warns + MockExtractor (state machine still walks, but
LLM-extracted fields are empty)
* extractor::OpenAiExtractor gains new_with_model(proxy, key, model);
model is now per-instance instead of compile-time DEFAULT_MODEL
3. start_listener extracted as helper — keeps run_serve readable across
the two feature-gated branches.
Verify-before-commit:
* cargo check -p kei-buddy (default): PASS
* cargo check -p kei-buddy --features extractor-openai: PASS
* cargo test -p kei-buddy --lib: 20/0 unchanged