KeiSeiKit-1.0/_primitives/_rust/kei-buddy/src/commands.rs
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

184 lines
6.5 KiB
Rust

// 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,
SyncGoogle,
SyncApple,
}
/// 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\
/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.
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()));
}
if lower.eq("sync-google") {
return Some(Command::SyncGoogle);
}
if lower.eq("sync-apple") {
return Some(Command::SyncApple);
}
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,
Command::SyncGoogle => exec::exec_sync_google(stores.contacts).await,
Command::SyncApple => exec::exec_sync_apple(stores.contacts).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("контакт"));
}
#[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"));
}
}