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)
145 lines
5.3 KiB
Rust
145 lines
5.3 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
//! Command execution helpers — one function per slash-command.
|
|
//! Called by `commands::execute_command`; not public API.
|
|
|
|
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 {
|
|
Err(e) => format!("ошибка при получении тем: {e}"),
|
|
Ok(list) if list.is_empty() => "тем пока нет".to_string(),
|
|
Ok(list) => {
|
|
let mut out = String::new();
|
|
for (i, unit) in list.iter().take(10).enumerate() {
|
|
let slug = unit.source_path.split('/').last().unwrap_or(&unit.source_path);
|
|
out.push_str(&format!("{}. {} ({})\n", i + 1, unit.title, slug));
|
|
}
|
|
out.trim_end().to_string()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn exec_contacts(contacts: &Contacts) -> String {
|
|
match contacts.search_contacts("", 10).await {
|
|
Err(_) => "контакты пусты".to_string(),
|
|
Ok(list) if list.is_empty() => "контакты пусты".to_string(),
|
|
Ok(list) => format_people(&list),
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn exec_whois(name: &str, contacts: &Contacts) -> String {
|
|
if name.is_empty() {
|
|
return "использование: /whois <имя>".to_string();
|
|
}
|
|
match contacts.search_contacts(name, 5).await {
|
|
Err(e) => format!("ошибка поиска: {e}"),
|
|
Ok(list) if list.is_empty() => format!("не найдено никого по запросу '{name}'"),
|
|
Ok(list) => {
|
|
let mut out = format_people(&list);
|
|
append_common_connections(&mut out, &list, contacts).await;
|
|
out
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn append_common_connections(
|
|
out: &mut String,
|
|
list: &[kei_social_store::people::Person],
|
|
contacts: &Contacts,
|
|
) {
|
|
if list.len() <= 1 {
|
|
return;
|
|
}
|
|
let top_id = list[0].id;
|
|
for hit in list.iter().skip(1).take(4) {
|
|
if let Ok(cc) = contacts.common_connections(top_id, hit.id).await {
|
|
if !cc.is_empty() {
|
|
let ids: Vec<String> = cc.iter().map(|id| id.to_string()).collect();
|
|
out.push_str(&format!(
|
|
"\nобщие знакомые ({} и {}): {}",
|
|
list[0].name,
|
|
hit.name,
|
|
ids.join(", ")
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn exec_find(query: &str, chat_id: i64, chat_log: &ChatLog) -> String {
|
|
if query.is_empty() {
|
|
return "использование: /find <текст>".to_string();
|
|
}
|
|
match chat_log.search(query, Some(chat_id), 10).await {
|
|
Err(e) => format!("ошибка поиска в переписке: {e}"),
|
|
Ok(msgs) if msgs.is_empty() => "ничего не найдено в переписке".to_string(),
|
|
Ok(msgs) => {
|
|
let mut out = String::new();
|
|
for (i, msg) in msgs.iter().enumerate() {
|
|
let snippet = truncate(&msg.content, 80);
|
|
out.push_str(&format!("{}. [{}] {}…\n", i + 1, msg.role, snippet));
|
|
}
|
|
out.trim_end().to_string()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn format_people(list: &[kei_social_store::people::Person]) -> String {
|
|
let mut out = String::new();
|
|
for (i, p) in list.iter().enumerate() {
|
|
let detail = if !p.email.is_empty() {
|
|
p.email.clone()
|
|
} else if !p.organization.is_empty() {
|
|
p.organization.clone()
|
|
} else {
|
|
String::new()
|
|
};
|
|
if detail.is_empty() {
|
|
out.push_str(&format!("{}. {}\n", i + 1, p.name));
|
|
} else {
|
|
out.push_str(&format!("{}. {} — {}\n", i + 1, p.name, detail));
|
|
}
|
|
}
|
|
out.trim_end().to_string()
|
|
}
|
|
|
|
fn truncate(s: &str, max_chars: usize) -> &str {
|
|
match s.char_indices().nth(max_chars) {
|
|
None => s,
|
|
Some((idx, _)) => &s[..idx],
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn exec_sync_google(contacts: &Arc<Contacts>) -> 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<Contacts>) -> 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()
|
|
)
|
|
}
|