feat(kei-buddy fleet): 5 atomics — google/apple contacts + classifier + tick + slash-commands
Parallel agent batch. All five tasks delivered functional + tested.
NOT deployed — user is in live conversation with the bot.
## Crates added (2 new)
### kei-contacts-google (466 LOC, 5 tests)
Thin Google People API client. Takes pre-acquired access_token from
kei-auth-google's OAuth flow; calls /v1/people/me/connections?personFields=...,
parses 200-entry first page (TODO: pagination via nextPageToken), maps
to kei_social_store::Person. Errors: Http / Auth(401) / Parse.
### kei-contacts-apple (593 LOC, 7 tests + 1 doc-test)
CardDAV client for iCloud Contacts using Basic Auth (Apple ID +
app-specific password). Sends REPORT with addressbook-query XML body,
parses multistatus → embedded vCards → AppleContact. Tiny vCard
parser (~150 LOC) handles FN/N/EMAIL/TEL/ORG/NOTE/UID, single-line
only (no line-folding for MVP). Discovery (PROPFIND .well-known/carddav
→ principal → addressbook-home-set) deferred — user supplies
addressbook URL via with_addressbook_url().
Both crates registered in workspace members.
## kei-buddy crate additions
### src/topic_classify.rs (116 LOC, 3 tests)
Free fn classify_and_store_topic(extractor, topics, chat_id, text)
called from process_text when state == OnboardState::Ready. Builds
classifier prompt → LLM → parses {slug, title} → validates slug
shape (kebab-case, ascii) → Topics::add_topic + add_digest. All
failure paths log + return; conversation never blocks.
### src/tick.rs (188 LOC, 3 integration tests) + src/bin/kei-buddy-tick.rs (67 LOC)
Second binary. Oneshot CLI for systemd timer: walks all known
chat_ids in BuddyStore → lists topics → searches recent chat
messages per topic (configurable window/limit) → LLM digest →
Topics::add_digest. Outputs JSON TickReport to stdout. Env-driven
config. NoOpExtractor fallback when no LLM creds (graceful degradation).
### src/commands.rs (146 LOC) + src/command_exec.rs (111 LOC, 7 tests)
Slash-commands intercepted BEFORE handle_step in process_text:
/whois <name> contacts.search_contacts + common_connections for hits
/find <q> chat_log.search scoped to chat_id
/topics topics.list_topics
/contacts contacts.search_contacts("", 10)
/help static usage text (Russian)
If command parsed, response built from stores, sent, logged to
chat_log — FSM skipped for that turn.
### src/serve_runner.rs (69 LOC) — refactor
run_serve + start_listener + init_tracing extracted out of serve.rs
to bring serve.rs back to 189 LOC (was 248 after previous wave).
### Wiring
BuddyContext gains `contacts: Arc<Contacts>` and `topics: Arc<Topics>`.
ServeConfig gains contacts_db_path + topics_db_path. Binary reads
KEI_BUDDY_CONTACTS_DB_PATH + KEI_BUDDY_TOPICS_DB_PATH env (defaults
./kei-buddy-contacts.db, ./kei-buddy-topics.db). cmd_migrate applies
schema for all three side-stores (chat_log + contacts + topics).
## Verify-before-commit (RULE 0.13 §)
* cargo check -p kei-buddy (default + extractor-openai): PASS
* cargo test -p kei-buddy --lib: 41 passed / 0 failed (was 31)
* cargo test -p kei-buddy --tests: 3 passed (tick integration)
* cargo build -p kei-buddy --features extractor-openai: PASS
(builds both kei-buddy + kei-buddy-tick binaries)
* cargo check -p kei-contacts-google: PASS (5 tests)
* cargo check -p kei-contacts-apple: PASS (7 + 1 doc)
* cargo check --workspace: PASS
## STATUS-TRUTH from all 5 agents: shipped=functional, behaviour-verified=yes
## Follow-up (deferred, non-blocking)
* Google People API pagination (nextPageToken loop) — first 200 only
* CardDAV auto-discovery (PROPFIND .well-known/carddav)
* vCard line-folding (RFC 6350 §3.2)
* Wire kei-contacts-google + kei-contacts-apple → Contacts.add_contact
sync command (no glue yet)
* systemd timer file for kei-buddy-tick (not shipped here — config only)
This commit is contained in:
parent
ff74c5554e
commit
3f2aa1189b
31 changed files with 2036 additions and 91 deletions
32
_primitives/_rust/Cargo.lock
generated
32
_primitives/_rust/Cargo.lock
generated
|
|
@ -3192,8 +3192,12 @@ dependencies = [
|
|||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"kei-chat-store",
|
||||
"kei-memory-sqlite",
|
||||
"kei-sage",
|
||||
"kei-social-store",
|
||||
"kei-telegram-webhook",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
|
|
@ -3336,6 +3340,34 @@ dependencies = [
|
|||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-contacts-apple"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kei-social-store",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-contacts-google"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kei-social-store",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kei-content-store"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -187,6 +187,10 @@ members = [
|
|||
"kei-tts",
|
||||
# STT abstraction — 3 backends (whisper-local/Deepgram/OpenAI-Whisper) behind feature flags
|
||||
"kei-stt",
|
||||
# Wave N — Google People API thin client (token-in, Vec<GoogleContact>-out)
|
||||
"kei-contacts-google",
|
||||
# Wave N+1 — iCloud CardDAV thin client (Basic Auth, Vec<AppleContact>-out)
|
||||
"kei-contacts-apple",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ license.workspace = true
|
|||
name = "kei-buddy"
|
||||
path = "src/bin/kei-buddy.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "kei-buddy-tick"
|
||||
path = "src/bin/kei-buddy-tick.rs"
|
||||
|
||||
[lib]
|
||||
name = "kei_buddy"
|
||||
path = "src/lib.rs"
|
||||
|
|
|
|||
67
_primitives/_rust/kei-buddy/src/bin/kei-buddy-tick.rs
Normal file
67
_primitives/_rust/kei-buddy/src/bin/kei-buddy-tick.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//! `kei-buddy-tick` — oneshot digest-generation binary.
|
||||
//!
|
||||
//! Meant for systemd timer / cron. Reads env, calls `run_tick`,
|
||||
//! prints JSON report to stdout, exits 0.
|
||||
|
||||
use kei_buddy::{run_tick, TickConfig};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init_log();
|
||||
let cfg = cfg_from_env();
|
||||
match run_tick(cfg).await {
|
||||
Ok(report) => {
|
||||
let out = serde_json::json!({
|
||||
"topics_processed": report.topics_processed,
|
||||
"digests_created": report.digests_created,
|
||||
"errors": report.errors,
|
||||
});
|
||||
println!("{}", out);
|
||||
}
|
||||
Err(e) => {
|
||||
let out = serde_json::json!({
|
||||
"fatal": e.to_string(),
|
||||
"topics_processed": 0,
|
||||
"digests_created": 0,
|
||||
"errors": [e.to_string()],
|
||||
});
|
||||
// Print to stdout so callers can parse; fatal still exits 0.
|
||||
println!("{}", out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cfg_from_env() -> TickConfig {
|
||||
TickConfig {
|
||||
buddy_db_path: env_or("KEI_BUDDY_DB_PATH", "./kei-buddy.db"),
|
||||
chat_log_db_path: env_or("KEI_BUDDY_CHAT_LOG_PATH", "./kei-buddy-chat.db"),
|
||||
topics_db_path: env_or("KEI_BUDDY_TOPICS_DB_PATH", "./kei-buddy-topics.db"),
|
||||
since_hours: env_i64("KEI_BUDDY_TICK_SINCE_HOURS", 24),
|
||||
max_messages_per_topic: env_i64("KEI_BUDDY_TICK_MAX_MESSAGES", 50),
|
||||
llm_proxy_url: std::env::var("KEI_BUDDY_LLM_PROXY").ok(),
|
||||
llm_api_key: std::env::var("KEI_BUDDY_LLM_KEY")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("OPENAI_API_KEY").ok()),
|
||||
llm_model: std::env::var("KEI_BUDDY_LLM_MODEL").ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_or(name: &str, default: &str) -> String {
|
||||
std::env::var(name).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
|
||||
fn env_i64(name: &str, default: i64) -> i64 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn init_log() {
|
||||
#[cfg(feature = "serve")]
|
||||
{
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
let _ = fmt().with_env_filter(EnvFilter::from_default_env()).try_init();
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,8 @@ async fn cmd_serve() -> anyhow::Result<()> {
|
|||
.or_else(|| std::env::var("OPENAI_API_KEY").ok()),
|
||||
llm_model: std::env::var("KEI_BUDDY_LLM_MODEL").ok(),
|
||||
chat_log_db_path: chat_log_path_from_env(),
|
||||
topics_db_path: topics_db_path_from_env(),
|
||||
contacts_db_path: contacts_db_path_from_env(),
|
||||
};
|
||||
run_serve(cfg).await
|
||||
}
|
||||
|
|
@ -87,8 +89,18 @@ fn cmd_migrate() -> anyhow::Result<()> {
|
|||
let _store = kei_buddy::store::SqliteBuddyStore::from_path(&path)?;
|
||||
let chat_log_path = chat_log_path_from_env();
|
||||
let _ = kei_buddy::ChatLog::from_path(&chat_log_path)?;
|
||||
let topics_path = topics_db_path_from_env();
|
||||
let _ = kei_buddy::Topics::from_path(&topics_path)?;
|
||||
let contacts_path = contacts_db_path_from_env();
|
||||
let _ = kei_buddy::Contacts::from_path(&contacts_path)?;
|
||||
init_log();
|
||||
tracing::info!(path = %path, chat_log_path = %chat_log_path, "schema applied");
|
||||
tracing::info!(
|
||||
path = %path,
|
||||
chat_log_path = %chat_log_path,
|
||||
topics_path = %topics_path,
|
||||
contacts_path = %contacts_path,
|
||||
"schema applied"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -149,3 +161,13 @@ fn db_path_from_env() -> String {
|
|||
fn chat_log_path_from_env() -> String {
|
||||
std::env::var("KEI_BUDDY_CHAT_LOG_PATH").unwrap_or_else(|_| "./kei-buddy-chat.db".into())
|
||||
}
|
||||
|
||||
fn topics_db_path_from_env() -> String {
|
||||
std::env::var("KEI_BUDDY_TOPICS_DB_PATH")
|
||||
.unwrap_or_else(|_| "./kei-buddy-topics.db".into())
|
||||
}
|
||||
|
||||
fn contacts_db_path_from_env() -> String {
|
||||
std::env::var("KEI_BUDDY_CONTACTS_DB_PATH")
|
||||
.unwrap_or_else(|_| "./kei-buddy-contacts.db".into())
|
||||
}
|
||||
|
|
|
|||
111
_primitives/_rust/kei-buddy/src/command_exec.rs
Normal file
111
_primitives/_rust/kei-buddy/src/command_exec.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//! Command execution helpers — one function per slash-command.
|
||||
//! Called by `commands::execute_command`; not public API.
|
||||
|
||||
use crate::{chat_log::ChatLog, contacts::Contacts, 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],
|
||||
}
|
||||
}
|
||||
146
_primitives/_rust/kei-buddy/src/commands.rs
Normal file
146
_primitives/_rust/kei-buddy/src/commands.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// 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,
|
||||
}
|
||||
|
||||
/// 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\
|
||||
/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()));
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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("контакт"));
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@
|
|||
//! * `store` — `BuddyStore` trait + `SqliteBuddyStore` impl
|
||||
|
||||
pub mod chat_log;
|
||||
pub(crate) mod command_exec;
|
||||
pub mod commands;
|
||||
pub mod contacts;
|
||||
pub mod error;
|
||||
pub mod extractor;
|
||||
|
|
@ -21,20 +23,26 @@ pub mod schema;
|
|||
pub mod state;
|
||||
pub mod store;
|
||||
pub(crate) mod store_ops;
|
||||
pub mod tick;
|
||||
pub mod topic_classify;
|
||||
pub mod topics;
|
||||
pub mod transition;
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod serve;
|
||||
#[cfg(feature = "serve")]
|
||||
pub(crate) mod serve_runner;
|
||||
#[cfg(feature = "serve")]
|
||||
pub mod serve_telegram;
|
||||
|
||||
pub use chat_log::ChatLog;
|
||||
pub use commands::{parse_command, execute_command, Command, CommandStores};
|
||||
pub use contacts::Contacts;
|
||||
pub use error::BuddyError;
|
||||
pub use extractor::LlmExtractor;
|
||||
pub use machine::handle_step;
|
||||
pub use state::OnboardState;
|
||||
pub use store::{BuddyStore, SqliteBuddyStore};
|
||||
pub use tick::{run_tick, run_tick_with, TickConfig, TickReport};
|
||||
pub use topics::Topics;
|
||||
pub use transition::StepOutput;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//! `run_serve` — axum router builder + BuddyContext impl.
|
||||
//! `BuddyContext` + axum router. Store bootstrap lives in `serve_runner`.
|
||||
//!
|
||||
//! Constructor Pattern: one responsibility — compose crate pieces into HTTP server.
|
||||
//! Each function ≤ 30 LOC. No logging of bot tokens.
|
||||
|
|
@ -15,6 +15,8 @@ use kei_telegram_webhook::{WebhookContext, WebhookEvent};
|
|||
|
||||
use crate::{
|
||||
chat_log::ChatLog,
|
||||
commands::{execute_command, parse_command, CommandStores},
|
||||
contacts::Contacts,
|
||||
error::BuddyError,
|
||||
extractor::LlmExtractor,
|
||||
machine::handle_step,
|
||||
|
|
@ -22,8 +24,12 @@ use crate::{
|
|||
serve_telegram::send_message,
|
||||
state::OnboardState,
|
||||
store::{BuddyStore, SqliteBuddyStore},
|
||||
topic_classify::classify_and_store_topic,
|
||||
topics::Topics,
|
||||
};
|
||||
|
||||
pub use crate::serve_runner::run_serve;
|
||||
|
||||
/// Configuration passed from the binary to `run_serve`.
|
||||
pub struct ServeConfig {
|
||||
pub port: u16,
|
||||
|
|
@ -38,6 +44,10 @@ pub struct ServeConfig {
|
|||
pub llm_model: Option<String>,
|
||||
/// Path to the SQLite file used by `ChatLog`. Default: `./kei-buddy-chat.db`.
|
||||
pub chat_log_db_path: String,
|
||||
/// Path to the SQLite file used by `Topics`. Default: `./kei-buddy-topics.db`.
|
||||
pub topics_db_path: String,
|
||||
/// Path to the SQLite file used by `Contacts`. Default: `./kei-buddy-contacts.db`.
|
||||
pub contacts_db_path: String,
|
||||
}
|
||||
|
||||
/// Axum state — implements `WebhookContext`. `Arc<E>` allows cheap `Clone`.
|
||||
|
|
@ -51,6 +61,10 @@ pub struct BuddyContext<E: LlmExtractor + Send + Sync + 'static> {
|
|||
pub allowed_chat_ids: Arc<Option<Vec<i64>>>,
|
||||
/// Persistent log of all Telegram messages (user + bot).
|
||||
pub chat_log: Arc<ChatLog>,
|
||||
/// Persistent topic store.
|
||||
pub topics: Arc<Topics>,
|
||||
/// Persistent contacts store.
|
||||
pub contacts: Arc<Contacts>,
|
||||
}
|
||||
|
||||
impl<E: LlmExtractor + Send + Sync + 'static> Clone for BuddyContext<E> {
|
||||
|
|
@ -63,6 +77,8 @@ impl<E: LlmExtractor + Send + Sync + 'static> Clone for BuddyContext<E> {
|
|||
http: self.http.clone(),
|
||||
allowed_chat_ids: Arc::clone(&self.allowed_chat_ids),
|
||||
chat_log: Arc::clone(&self.chat_log),
|
||||
topics: Arc::clone(&self.topics),
|
||||
contacts: Arc::clone(&self.contacts),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -107,19 +123,38 @@ impl<E: LlmExtractor + Send + Sync + 'static> BuddyContext<E> {
|
|||
if let Err(e) = self.chat_log.log_user(chat_id, text).await {
|
||||
error!(chat_id, error = %e, "chat_log failure");
|
||||
}
|
||||
let state = self
|
||||
.store
|
||||
.load_state(chat_id)
|
||||
.await?
|
||||
.unwrap_or(OnboardState::Intro);
|
||||
let persona = self
|
||||
.store
|
||||
.load_persona(chat_id)
|
||||
.await?
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
if let Some(cmd) = parse_command(text) {
|
||||
return self.dispatch_command(cmd, chat_id).await;
|
||||
}
|
||||
self.run_fsm(chat_id, text).await
|
||||
}
|
||||
|
||||
async fn dispatch_command(
|
||||
&self, cmd: crate::commands::Command<'_>, chat_id: i64,
|
||||
) -> Result<(), BuddyError> {
|
||||
let stores = CommandStores {
|
||||
chat_log: &self.chat_log,
|
||||
contacts: &self.contacts,
|
||||
topics: &self.topics,
|
||||
};
|
||||
let response = execute_command(cmd, chat_id, &stores).await;
|
||||
let _ = send_message(&self.bot_token, chat_id, &response, &self.http).await;
|
||||
if let Err(e) = self.chat_log.log_bot(chat_id, &response).await {
|
||||
error!(chat_id, error = %e, "chat_log failure");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_fsm(&self, chat_id: i64, text: &str) -> Result<(), BuddyError> {
|
||||
let state = self.store.load_state(chat_id).await?.unwrap_or(OnboardState::Intro);
|
||||
let was_ready = state == OnboardState::Ready;
|
||||
let persona = self.store.load_persona(chat_id).await?.unwrap_or_else(|| json!({}));
|
||||
let output = handle_step(&state, text, &persona, self.extractor.as_ref()).await?;
|
||||
self.store.save_state(chat_id, &output.next_state).await?;
|
||||
self.apply_persona_patch(chat_id, output.persona_patch).await?;
|
||||
if was_ready || output.next_state == OnboardState::Ready {
|
||||
classify_and_store_topic(self.extractor.as_ref(), self.topics.as_ref(), chat_id, text).await;
|
||||
}
|
||||
send_message(&self.bot_token, chat_id, &output.response_text, &self.http).await?;
|
||||
if let Err(e) = self.chat_log.log_bot(chat_id, &output.response_text).await {
|
||||
error!(chat_id, error = %e, "chat_log failure");
|
||||
|
|
@ -131,11 +166,7 @@ impl<E: LlmExtractor + Send + Sync + 'static> BuddyContext<E> {
|
|||
if patch == json!({}) {
|
||||
return Ok(());
|
||||
}
|
||||
let base = self
|
||||
.store
|
||||
.load_persona(chat_id)
|
||||
.await?
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let base = self.store.load_persona(chat_id).await?.unwrap_or_else(|| json!({}));
|
||||
let merged = deep_merge(base, patch);
|
||||
self.store.save_persona(chat_id, &merged).await
|
||||
}
|
||||
|
|
@ -143,11 +174,7 @@ impl<E: LlmExtractor + Send + Sync + 'static> BuddyContext<E> {
|
|||
|
||||
/// Health-check handler.
|
||||
async fn health() -> Json<Value> {
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"crate": "kei-buddy",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}))
|
||||
Json(json!({ "status": "ok", "crate": "kei-buddy", "version": env!("CARGO_PKG_VERSION") }))
|
||||
}
|
||||
|
||||
/// Build the axum Router.
|
||||
|
|
@ -156,72 +183,7 @@ where
|
|||
E: LlmExtractor + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route(
|
||||
"/webhook",
|
||||
routing::post(kei_telegram_webhook::handle_webhook::<BuddyContext<E>>),
|
||||
)
|
||||
.route("/webhook", routing::post(kei_telegram_webhook::handle_webhook::<BuddyContext<E>>))
|
||||
.route("/health", routing::get(health))
|
||||
.with_state(ctx)
|
||||
}
|
||||
|
||||
/// Start the HTTP server.
|
||||
pub async fn run_serve(cfg: ServeConfig) -> anyhow::Result<()> {
|
||||
init_tracing();
|
||||
let store = Arc::new(SqliteBuddyStore::from_path(&cfg.db_path)?);
|
||||
let allowed_chat_ids = Arc::new(cfg.allowed_chat_ids);
|
||||
let http = reqwest::Client::new();
|
||||
let chat_log = Arc::new(ChatLog::from_path(&cfg.chat_log_db_path)?);
|
||||
|
||||
#[cfg(feature = "extractor-openai")]
|
||||
{
|
||||
if let (Some(proxy), Some(key)) = (cfg.llm_proxy_url, cfg.llm_api_key) {
|
||||
let model = cfg
|
||||
.llm_model
|
||||
.unwrap_or_else(|| "gpt-4o-mini".to_string());
|
||||
tracing::info!(model = %model, "using OpenAiExtractor (LiteLLM-compatible)");
|
||||
let extractor = Arc::new(crate::extractor::openai::OpenAiExtractor::new_with_model(
|
||||
proxy, key, model,
|
||||
));
|
||||
return start_listener(cfg.port, BuddyContext {
|
||||
secret: cfg.webhook_secret,
|
||||
bot_token: cfg.bot_token,
|
||||
store,
|
||||
extractor,
|
||||
http,
|
||||
allowed_chat_ids,
|
||||
chat_log,
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
|
||||
warn!("no LLM extractor configured — using MockExtractor (state machine will advance but field-extraction returns empty)");
|
||||
let extractor = Arc::new(crate::extractor::MockExtractor::new(json!({})));
|
||||
start_listener(cfg.port, BuddyContext {
|
||||
secret: cfg.webhook_secret,
|
||||
bot_token: cfg.bot_token,
|
||||
store,
|
||||
extractor,
|
||||
http,
|
||||
allowed_chat_ids,
|
||||
chat_log,
|
||||
}).await
|
||||
}
|
||||
|
||||
async fn start_listener<E>(port: u16, ctx: BuddyContext<E>) -> anyhow::Result<()>
|
||||
where
|
||||
E: LlmExtractor + Send + Sync + 'static,
|
||||
{
|
||||
let router = build_router(ctx);
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
tracing::info!(addr = %addr, "kei-buddy listening");
|
||||
axum::serve(listener, router).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
let _ = fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.try_init();
|
||||
}
|
||||
|
|
|
|||
69
_primitives/_rust/kei-buddy/src/serve_runner.rs
Normal file
69
_primitives/_rust/kei-buddy/src/serve_runner.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//! `run_serve` — store construction + HTTP listener bootstrap.
|
||||
//! Extracted from serve.rs to keep both files ≤ 200 LOC.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
chat_log::ChatLog,
|
||||
contacts::Contacts,
|
||||
extractor::LlmExtractor,
|
||||
serve::{BuddyContext, ServeConfig},
|
||||
store::SqliteBuddyStore,
|
||||
topics::Topics,
|
||||
};
|
||||
|
||||
/// Start the HTTP server (entry-point called from the binary).
|
||||
pub async fn run_serve(cfg: ServeConfig) -> anyhow::Result<()> {
|
||||
init_tracing();
|
||||
let store = Arc::new(SqliteBuddyStore::from_path(&cfg.db_path)?);
|
||||
let allowed_chat_ids = Arc::new(cfg.allowed_chat_ids);
|
||||
let http = reqwest::Client::new();
|
||||
let chat_log = Arc::new(ChatLog::from_path(&cfg.chat_log_db_path)?);
|
||||
let topics = Arc::new(Topics::from_path(&cfg.topics_db_path)?);
|
||||
let contacts = Arc::new(Contacts::from_path(&cfg.contacts_db_path)?);
|
||||
|
||||
#[cfg(feature = "extractor-openai")]
|
||||
{
|
||||
if let (Some(proxy), Some(key)) = (cfg.llm_proxy_url, cfg.llm_api_key) {
|
||||
let model = cfg.llm_model.unwrap_or_else(|| "gpt-4o-mini".to_string());
|
||||
tracing::info!(model = %model, "using OpenAiExtractor (LiteLLM-compatible)");
|
||||
let extractor = Arc::new(crate::extractor::openai::OpenAiExtractor::new_with_model(
|
||||
proxy, key, model,
|
||||
));
|
||||
return start_listener(cfg.port, BuddyContext {
|
||||
secret: cfg.webhook_secret,
|
||||
bot_token: cfg.bot_token,
|
||||
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts,
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
|
||||
warn!("no LLM extractor configured — using MockExtractor");
|
||||
let extractor = Arc::new(crate::extractor::MockExtractor::new(json!({})));
|
||||
start_listener(cfg.port, BuddyContext {
|
||||
secret: cfg.webhook_secret,
|
||||
bot_token: cfg.bot_token,
|
||||
store, extractor, http, allowed_chat_ids, chat_log, topics, contacts,
|
||||
}).await
|
||||
}
|
||||
|
||||
async fn start_listener<E>(port: u16, ctx: BuddyContext<E>) -> anyhow::Result<()>
|
||||
where
|
||||
E: LlmExtractor + Send + Sync + 'static,
|
||||
{
|
||||
let router = crate::serve::build_router(ctx);
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
tracing::info!(addr = %addr, "kei-buddy listening");
|
||||
axum::serve(listener, router).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
let _ = fmt().with_env_filter(EnvFilter::from_default_env()).try_init();
|
||||
}
|
||||
|
|
@ -66,6 +66,14 @@ impl SqliteBuddyStore {
|
|||
let store = Arc::new(SqliteStore::from_memory()?);
|
||||
Self::new(store)
|
||||
}
|
||||
|
||||
/// Lock and return the inner SQLite connection guard.
|
||||
///
|
||||
/// Used by `tick::load_chat_ids_from_store` to read `buddy_state` chat_ids.
|
||||
/// Callers must not hold the guard across `await` points.
|
||||
pub fn inner_conn(&self) -> std::sync::MutexGuard<'_, rusqlite::Connection> {
|
||||
self.inner.lock()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BuddyStore impl ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
188
_primitives/_rust/kei-buddy/src/tick.rs
Normal file
188
_primitives/_rust/kei-buddy/src/tick.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//! `run_tick` — one-shot digest generator for all tracked topics.
|
||||
//!
|
||||
//! Constructor Pattern: one responsibility — walk topics, collect recent
|
||||
//! messages, call LLM, persist digests. Called from `kei-buddy-tick` bin.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
chat_log::ChatLog,
|
||||
error::BuddyError,
|
||||
extractor::LlmExtractor,
|
||||
topics::Topics,
|
||||
};
|
||||
|
||||
// ─── public types ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for a `run_tick` invocation (file paths + tuning knobs).
|
||||
pub struct TickConfig {
|
||||
pub buddy_db_path: String,
|
||||
pub chat_log_db_path: String,
|
||||
pub topics_db_path: String,
|
||||
/// Only messages newer than `now - since_hours * 3600` are included.
|
||||
pub since_hours: i64,
|
||||
pub max_messages_per_topic: i64,
|
||||
pub llm_proxy_url: Option<String>,
|
||||
pub llm_api_key: Option<String>,
|
||||
pub llm_model: Option<String>,
|
||||
}
|
||||
|
||||
/// Summary returned after one tick run.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TickReport {
|
||||
pub topics_processed: usize,
|
||||
pub digests_created: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
// ─── public entry points ─────────────────────────────────────────────────────
|
||||
|
||||
/// Open DBs from `cfg` paths, discover chat_ids, run digests.
|
||||
///
|
||||
/// Exits 0 on per-topic errors — they are collected in `TickReport.errors`.
|
||||
pub async fn run_tick(cfg: TickConfig) -> Result<TickReport, BuddyError> {
|
||||
let chat_log = Arc::new(ChatLog::from_path(&cfg.chat_log_db_path)?);
|
||||
let topics = Arc::new(Topics::from_path(&cfg.topics_db_path)?);
|
||||
let extractor = build_extractor(&cfg);
|
||||
let chat_ids = load_chat_ids(&cfg.buddy_db_path)?;
|
||||
run_tick_with(chat_log, topics, extractor, cfg.since_hours, cfg.max_messages_per_topic, chat_ids).await
|
||||
}
|
||||
|
||||
/// Testable core: accepts pre-built instances and an explicit chat_id list.
|
||||
pub async fn run_tick_with(
|
||||
chat_log: Arc<ChatLog>,
|
||||
topics: Arc<Topics>,
|
||||
extractor: Arc<dyn LlmExtractor>,
|
||||
since_hours: i64,
|
||||
max_messages: i64,
|
||||
known_chat_ids: Vec<i64>,
|
||||
) -> Result<TickReport, BuddyError> {
|
||||
let mut report = TickReport::default();
|
||||
let cutoff = now_epoch() - since_hours * 3600;
|
||||
for chat_id in known_chat_ids {
|
||||
let topic_units = match topics.list_topics(chat_id).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => { report.errors.push(format!("list_topics({chat_id}): {e}")); continue; }
|
||||
};
|
||||
for unit in topic_units {
|
||||
report.topics_processed += 1;
|
||||
process_topic(&chat_log, &topics, &*extractor, chat_id, &unit, cutoff, max_messages, &mut report).await;
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Collect recent messages for one topic, call extractor, persist digest.
|
||||
async fn process_topic(
|
||||
chat_log: &ChatLog,
|
||||
topics: &Topics,
|
||||
extractor: &dyn LlmExtractor,
|
||||
chat_id: i64,
|
||||
unit: &kei_sage::Unit,
|
||||
cutoff: i64,
|
||||
max_messages: i64,
|
||||
report: &mut TickReport,
|
||||
) {
|
||||
let slug = slug_from_path(&unit.source_path);
|
||||
let msgs = match chat_log.search(&unit.title, Some(chat_id), max_messages).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => { report.errors.push(format!("search({chat_id}, {}): {e}", unit.title)); return; }
|
||||
};
|
||||
let recent: Vec<_> = msgs.into_iter().filter(|m| m.created_at >= cutoff).collect();
|
||||
if recent.is_empty() { return; }
|
||||
let val = match extractor.extract(&digest_system_prompt(&unit.title), &concat_messages(&recent)).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => { report.errors.push(format!("extract({chat_id}, {}): {e}", unit.title)); return; }
|
||||
};
|
||||
if val.is_null() || val.as_object().map(|m| m.is_empty()).unwrap_or(false) {
|
||||
report.errors.push(format!("extractor empty for '{}' in chat {chat_id}", unit.title));
|
||||
return;
|
||||
}
|
||||
let text = val.as_str().map(|s| s.to_string()).unwrap_or_else(|| val.to_string());
|
||||
match topics.add_digest(chat_id, slug, now_epoch(), &text).await {
|
||||
Ok(_) => report.digests_created += 1,
|
||||
Err(e) => report.errors.push(format!("add_digest({chat_id}, {slug}): {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn now_epoch() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn slug_from_path(source_path: &str) -> &str {
|
||||
source_path.rfind("topic/").map(|i| &source_path[i + 6..]).unwrap_or(source_path)
|
||||
}
|
||||
|
||||
fn concat_messages(msgs: &[kei_chat_store::sessions::ChatMessage]) -> String {
|
||||
let mut out = String::new();
|
||||
for m in msgs {
|
||||
out.push_str(&m.content);
|
||||
out.push('\n');
|
||||
if out.len() > 2000 { break; }
|
||||
}
|
||||
out.truncate(2000);
|
||||
out
|
||||
}
|
||||
|
||||
fn digest_system_prompt(title: &str) -> String {
|
||||
format!(
|
||||
"You are a digest writer. Summarise the following messages about \"{title}\" \
|
||||
into 2-4 bullet points in the user's language. Output plain text, no markdown \
|
||||
headers, no preamble."
|
||||
)
|
||||
}
|
||||
|
||||
/// Read distinct chat_ids from buddy_state table.
|
||||
fn load_chat_ids(buddy_db_path: &str) -> Result<Vec<i64>, BuddyError> {
|
||||
use crate::store::SqliteBuddyStore;
|
||||
let store = SqliteBuddyStore::from_path(buddy_db_path)?;
|
||||
load_chat_ids_from_store(&store)
|
||||
}
|
||||
|
||||
/// Extract chat_ids from a BuddyStore (visible for testing via in-memory store).
|
||||
pub fn load_chat_ids_from_store(store: &crate::store::SqliteBuddyStore) -> Result<Vec<i64>, BuddyError> {
|
||||
let conn = store.inner_conn();
|
||||
let mut stmt = conn.prepare("SELECT DISTINCT chat_id FROM buddy_state")
|
||||
.map_err(|e| BuddyError::Memory(e.to_string()))?;
|
||||
let ids: Vec<i64> = stmt
|
||||
.query_map([], |r| r.get(0))
|
||||
.map_err(|e| BuddyError::Memory(e.to_string()))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn build_extractor(cfg: &TickConfig) -> Arc<dyn LlmExtractor> {
|
||||
#[cfg(feature = "extractor-openai")]
|
||||
{
|
||||
let proxy = cfg.llm_proxy_url.clone().or_else(|| Some("https://api.openai.com".to_string()));
|
||||
let key = cfg.llm_api_key.clone().or_else(|| std::env::var("OPENAI_API_KEY").ok());
|
||||
if let (Some(proxy_url), Some(api_key)) = (proxy, key) {
|
||||
use crate::extractor::openai::OpenAiExtractor;
|
||||
let ext = match &cfg.llm_model {
|
||||
Some(m) => OpenAiExtractor::new_with_model(proxy_url, api_key, m.clone()),
|
||||
None => OpenAiExtractor::new(proxy_url, api_key),
|
||||
};
|
||||
return Arc::new(ext);
|
||||
}
|
||||
}
|
||||
let _ = cfg; // suppress unused warning when feature off
|
||||
Arc::new(NoOpExtractor)
|
||||
}
|
||||
|
||||
/// Fallback extractor when `extractor-openai` is not compiled or creds are absent.
|
||||
struct NoOpExtractor;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LlmExtractor for NoOpExtractor {
|
||||
async fn extract(&self, _system: &str, _user_text: &str) -> Result<serde_json::Value, BuddyError> {
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
109
_primitives/_rust/kei-buddy/src/topic_classify.rs
Normal file
109
_primitives/_rust/kei-buddy/src/topic_classify.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//! Topic classification helper — free function invoked after `OnboardState::Ready`.
|
||||
//!
|
||||
//! Constructor Pattern: one responsibility — LLM classify + Topics store, fire-and-forget.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::{extractor::LlmExtractor, topics::Topics};
|
||||
|
||||
const CLASSIFY_PROMPT: &str = concat!(
|
||||
"You are a topic classifier. Output a single JSON object with two string fields: ",
|
||||
"\"slug\" (kebab-case, ascii, ≤30 chars, like \"work-meetings\") and ",
|
||||
"\"title\" (human-readable in the user's language, ≤50 chars). ",
|
||||
"Classify the following user message into ONE topic. ",
|
||||
"Output only the JSON, no prose, no markdown fences."
|
||||
);
|
||||
|
||||
/// Classify `text` into a topic and store it in `topics`. Never panics; never returns `Err`.
|
||||
pub async fn classify_and_store_topic(
|
||||
extractor: &dyn LlmExtractor,
|
||||
topics: &Topics,
|
||||
chat_id: i64,
|
||||
text: &str,
|
||||
) {
|
||||
let val = match extractor.extract(CLASSIFY_PROMPT, text).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(chat_id, error = %e, "topic classifier LLM call failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let slug = match val.get("slug").and_then(|v| v.as_str()) {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => {
|
||||
warn!(chat_id, "topic classifier returned no slug field");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let title = match val.get("title").and_then(|v| v.as_str()) {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => {
|
||||
warn!(chat_id, "topic classifier returned no title field");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !is_valid_slug(&slug) {
|
||||
warn!(chat_id, slug = %slug, "topic slug failed validation; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = topics.add_topic(chat_id, &slug, &title, text).await {
|
||||
error!(chat_id, slug = %slug, error = %e, "topics.add_topic failed");
|
||||
}
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
if let Err(e) = topics.add_digest(chat_id, &slug, now, text).await {
|
||||
error!(chat_id, slug = %slug, error = %e, "topics.add_digest failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_slug(slug: &str) -> bool {
|
||||
!slug.is_empty()
|
||||
&& slug.len() <= 40
|
||||
&& slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{extractor::MockExtractor, topics::Topics};
|
||||
use serde_json::json;
|
||||
|
||||
async fn make_topics() -> Topics {
|
||||
Topics::from_memory().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classify_and_store_skips_invalid_slug() {
|
||||
let extractor = MockExtractor::new(json!({"slug": "has spaces", "title": "X"}));
|
||||
let topics = make_topics().await;
|
||||
classify_and_store_topic(&extractor, &topics, 1, "hello").await;
|
||||
assert!(topics.list_topics(1).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classify_and_store_adds_topic_for_valid_slug() {
|
||||
let extractor = MockExtractor::new(json!({"slug": "work-stuff", "title": "Work Stuff"}));
|
||||
let topics = make_topics().await;
|
||||
classify_and_store_topic(&extractor, &topics, 1, "I have a meeting").await;
|
||||
assert_eq!(topics.list_topics(1).await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classify_and_store_handles_missing_fields() {
|
||||
let extractor = MockExtractor::new(json!({}));
|
||||
let topics = make_topics().await;
|
||||
classify_and_store_topic(&extractor, &topics, 1, "any text").await;
|
||||
assert!(topics.list_topics(1).await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
79
_primitives/_rust/kei-buddy/tests/tick_tests.rs
Normal file
79
_primitives/_rust/kei-buddy/tests/tick_tests.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//! Integration tests for `tick::run_tick_with`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kei_buddy::{
|
||||
chat_log::ChatLog,
|
||||
extractor::{LlmExtractor, MockExtractor},
|
||||
run_tick_with,
|
||||
topics::Topics,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn mock(v: serde_json::Value) -> Arc<dyn LlmExtractor> {
|
||||
Arc::new(MockExtractor::new(v))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tick_no_topics_returns_zero() {
|
||||
let chat_log = Arc::new(ChatLog::from_memory().unwrap());
|
||||
let topics = Arc::new(Topics::from_memory().unwrap());
|
||||
let report = run_tick_with(chat_log, topics, mock(json!("digest")), 24, 50, vec![42])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.topics_processed, 0);
|
||||
assert_eq!(report.digests_created, 0);
|
||||
assert!(report.errors.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tick_with_topic_and_messages_creates_digest() {
|
||||
let chat_log = Arc::new(ChatLog::from_memory().unwrap());
|
||||
let topics = Arc::new(Topics::from_memory().unwrap());
|
||||
|
||||
// Title "rust" so FTS search on "rust" matches messages containing "rust".
|
||||
topics.add_topic(42, "rust", "rust", "about rust").await.unwrap();
|
||||
chat_log.log_user(42, "I love rust borrow checker").await.unwrap();
|
||||
chat_log.log_user(42, "rust ownership is great").await.unwrap();
|
||||
|
||||
let report = run_tick_with(
|
||||
chat_log,
|
||||
topics.clone(),
|
||||
mock(json!("• Rust is great\n• Ownership rules")),
|
||||
24,
|
||||
50,
|
||||
vec![42],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.topics_processed, 1, "errors: {:?}", report.errors);
|
||||
assert_eq!(report.digests_created, 1, "errors: {:?}", report.errors);
|
||||
|
||||
let digests = topics.digests_for(42, "rust").await.unwrap();
|
||||
assert_eq!(digests.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tick_skips_topic_without_recent_messages() {
|
||||
let chat_log = Arc::new(ChatLog::from_memory().unwrap());
|
||||
let topics = Arc::new(Topics::from_memory().unwrap());
|
||||
|
||||
topics.add_topic(42, "go", "Go Programming", "about go").await.unwrap();
|
||||
// No messages added — topic has 0 recent messages, skip digest.
|
||||
|
||||
let report = run_tick_with(
|
||||
chat_log,
|
||||
topics,
|
||||
mock(json!("• Go is fine")),
|
||||
24,
|
||||
50,
|
||||
vec![42],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.topics_processed, 1);
|
||||
assert_eq!(report.digests_created, 0);
|
||||
}
|
||||
|
|
@ -29,6 +29,25 @@ fn extract_wikilinks(content: &str) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Normalize a wikilink target to a basename comparable against
|
||||
/// `all_basenames` (file_stem-based index).
|
||||
///
|
||||
/// Returns `None` when the target escapes the scan root via `../` —
|
||||
/// such refs point outside the scan tree (e.g. `~/.claude/rules/*` from
|
||||
/// inside a sync-repo MEMORY.md) and cannot be validated by this scanner.
|
||||
///
|
||||
/// For path-prefixed targets (`chatlogs/X/Y`, `concepts/Z`) only the
|
||||
/// last segment is compared, matching how `all_basenames` builds its
|
||||
/// index. The `.md` suffix is stripped — `file_stem` does the same.
|
||||
fn normalize_target(raw: &str) -> Option<String> {
|
||||
if raw.starts_with("../") || raw.contains("/../") {
|
||||
return None;
|
||||
}
|
||||
let bn = raw.rsplit('/').next().unwrap_or(raw);
|
||||
let bn = bn.strip_suffix(".md").unwrap_or(bn);
|
||||
Some(bn.to_string())
|
||||
}
|
||||
|
||||
fn extract_handoffs(content: &str) -> Vec<String> {
|
||||
let rx = Regex::new(r"(?im)^\s*-\s*\*\*([a-z0-9][a-z0-9_-]{2,})\*\*").expect("static regex");
|
||||
rx.captures_iter(content)
|
||||
|
|
@ -48,9 +67,12 @@ pub fn scan(root: &Path) -> Vec<Conflict> {
|
|||
}
|
||||
let content = read_lossy(e.path());
|
||||
let file_rel = rel(root, e.path());
|
||||
for target in extract_wikilinks(&content) {
|
||||
if !index.contains(&target) {
|
||||
out.push(orphan(&file_rel, &target, "wikilink"));
|
||||
for raw in extract_wikilinks(&content) {
|
||||
let Some(normalized) = normalize_target(&raw) else {
|
||||
continue;
|
||||
};
|
||||
if !index.contains(&normalized) {
|
||||
out.push(orphan(&file_rel, &raw, "wikilink"));
|
||||
}
|
||||
}
|
||||
for target in extract_handoffs(&content) {
|
||||
|
|
@ -72,3 +94,43 @@ fn orphan(file: &str, target: &str, kind: &str) -> Conflict {
|
|||
true,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cross_repo_ref_skipped() {
|
||||
assert_eq!(normalize_target("../../../rules/recurrence-escalate"), None);
|
||||
assert_eq!(normalize_target("../foo"), None);
|
||||
assert_eq!(normalize_target("docs/../escape"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_prefixed_target_basenamed() {
|
||||
assert_eq!(
|
||||
normalize_target("chatlogs/ml-keilab/2026-05-08-something"),
|
||||
Some("2026-05-08-something".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_target("concepts/keibeta"),
|
||||
Some("keibeta".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_basename_passes_through() {
|
||||
assert_eq!(
|
||||
normalize_target("ai-creative-engine"),
|
||||
Some("ai-creative-engine".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn md_suffix_stripped() {
|
||||
assert_eq!(
|
||||
normalize_target("docs/intro.md"),
|
||||
Some("intro".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,26 @@ fn orphan_wikilinks_flagged() {
|
|||
assert_eq!(v["conflicts"][0]["category"], "orphans");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_repo_wikilink_not_flagged() {
|
||||
// `[[../../../rules/X]]` escapes the scan root — engine cannot validate,
|
||||
// must not false-positive.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write(tmp.path(), "memory/MEMORY.md", "see [[../../../rules/recurrence-escalate]]");
|
||||
let v = run(tmp.path(), &["--only", "orphans"]);
|
||||
assert_eq!(v["hit_count"].as_u64().unwrap(), 0, "{}", v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_prefixed_wikilink_matches_basename() {
|
||||
// `[[chatlogs/X/Y]]` should resolve when `Y.md` exists anywhere in the tree.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write(tmp.path(), "chatlogs/X/Y.md", "target body");
|
||||
write(tmp.path(), "memory/index.md", "ref to [[chatlogs/X/Y]]");
|
||||
let v = run(tmp.path(), &["--only", "orphans"]);
|
||||
assert_eq!(v["hit_count"].as_u64().unwrap(), 0, "{}", v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_file_flagged() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
|
|
|||
31
_primitives/_rust/kei-contacts-apple/Cargo.toml
Normal file
31
_primitives/_rust/kei-contacts-apple/Cargo.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
[package]
|
||||
name = "kei-contacts-apple"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "CardDAV client for iCloud Contacts. Authenticates with Apple ID + app-specific password (HTTP Basic Auth)."
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "kei_contacts_apple"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros"] }
|
||||
tracing = "0.1"
|
||||
regex = { workspace = true }
|
||||
kei-social-store = { path = "../kei-social-store" }
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
[package.metadata.keisei]
|
||||
maturity = "alpha"
|
||||
backend = "apple"
|
||||
description = "iCloud CardDAV thin client (Basic Auth, Vec<AppleContact>-out)"
|
||||
authors = ["Denis Parfionovich <parfionovich@keilab.io>"]
|
||||
52
_primitives/_rust/kei-contacts-apple/README.md
Normal file
52
_primitives/_rust/kei-contacts-apple/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# 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
|
||||
|
||||
```rust
|
||||
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.
|
||||
168
_primitives/_rust/kei-contacts-apple/src/client.rs
Normal file
168
_primitives/_rust/kei-contacts-apple/src/client.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! [`ICloudCardDavClient`] — CardDAV client for iCloud Contacts.
|
||||
|
||||
use crate::contact::AppleContact;
|
||||
use crate::error::ContactsError;
|
||||
use crate::xml::{addressbook_query_xml, extract_contacts_from_multistatus};
|
||||
use reqwest::{Client, Method};
|
||||
use tracing::debug;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://contacts.icloud.com";
|
||||
|
||||
/// CardDAV client for iCloud Contacts.
|
||||
///
|
||||
/// # Authentication
|
||||
/// iCloud requires an **app-specific password** (not the main Apple ID password
|
||||
/// and not Sign in with Apple). Generate one at <https://appleid.apple.com>.
|
||||
///
|
||||
/// # Discovery
|
||||
/// Full CardDAV discovery (PROPFIND `.well-known/carddav`) is complex. For the
|
||||
/// MVP, supply the addressbook URL directly via [`with_addressbook_url`].
|
||||
///
|
||||
/// [`with_addressbook_url`]: ICloudCardDavClient::with_addressbook_url
|
||||
pub struct ICloudCardDavClient {
|
||||
apple_id: String,
|
||||
app_specific_password: String,
|
||||
base_url: String,
|
||||
addressbook_url: Option<String>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ICloudCardDavClient {
|
||||
/// Construct a client with the given credentials and the production base URL.
|
||||
pub fn new(apple_id: String, app_specific_password: String) -> Self {
|
||||
Self {
|
||||
apple_id,
|
||||
app_specific_password,
|
||||
base_url: DEFAULT_BASE_URL.to_string(),
|
||||
addressbook_url: None,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the base URL (useful for wiremock tests).
|
||||
pub fn with_base_url(mut self, url: String) -> Self {
|
||||
self.base_url = url;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the full addressbook URL, skipping CardDAV discovery.
|
||||
///
|
||||
/// Example (iCloud): `https://p01-contacts.icloud.com/123456789/carddavhome/card/`
|
||||
pub fn with_addressbook_url(mut self, url: String) -> Self {
|
||||
self.addressbook_url = Some(url);
|
||||
self
|
||||
}
|
||||
|
||||
/// Fetch all contacts from the configured addressbook.
|
||||
///
|
||||
/// Issues a CardDAV REPORT `addressbook-query` and returns parsed contacts.
|
||||
pub async fn list_contacts(&self) -> Result<Vec<AppleContact>, ContactsError> {
|
||||
let url = self
|
||||
.addressbook_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.base_url.clone());
|
||||
|
||||
debug!(%url, "REPORT addressbook-query");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.request(
|
||||
Method::from_bytes(b"REPORT")
|
||||
.map_err(|e| ContactsError::Http(e.to_string()))?,
|
||||
&url,
|
||||
)
|
||||
.basic_auth(&self.apple_id, Some(&self.app_specific_password))
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.header("Depth", "1")
|
||||
.body(addressbook_query_xml())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ContactsError::Http(e.to_string()))?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
return Err(ContactsError::Auth(format!(
|
||||
"iCloud returned {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
if !status.is_success() && status.as_u16() != 207 {
|
||||
return Err(ContactsError::Http(format!("status={}", status)));
|
||||
}
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ContactsError::Parse(e.to_string()))?;
|
||||
|
||||
extract_contacts_from_multistatus(&text)
|
||||
}
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn two_contacts_xml() -> String {
|
||||
let vc1 = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alice Smith\r\nUID:uid-alice\r\nEMAIL:alice@example.com\r\nEND:VCARD";
|
||||
let vc2 = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bob Jones\r\nUID:uid-bob\r\nEMAIL:bob@example.com\r\nEND:VCARD";
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:response>
|
||||
<D:propstat><C:address-data>{vc1}</C:address-data></D:propstat>
|
||||
</D:response>
|
||||
<D:response>
|
||||
<D:propstat><C:address-data>{vc2}</C:address-data></D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>"#
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_contacts_parses_carddav_xml() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(two_contacts_xml()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = ICloudCardDavClient::new(
|
||||
"user@icloud.com".to_string(),
|
||||
"app-pass".to_string(),
|
||||
)
|
||||
.with_base_url(server.uri());
|
||||
|
||||
let contacts = client.list_contacts().await.expect("should succeed");
|
||||
assert_eq!(contacts.len(), 2);
|
||||
let names: Vec<_> = contacts.iter().map(|c| c.display_name.as_str()).collect();
|
||||
assert!(names.contains(&"Alice Smith"));
|
||||
assert!(names.contains(&"Bob Jones"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_401_maps_to_auth_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = ICloudCardDavClient::new(
|
||||
"user@icloud.com".to_string(),
|
||||
"wrong-pass".to_string(),
|
||||
)
|
||||
.with_base_url(server.uri());
|
||||
|
||||
let err = client.list_contacts().await.expect_err("should fail");
|
||||
assert!(matches!(err, ContactsError::Auth(_)));
|
||||
}
|
||||
}
|
||||
97
_primitives/_rust/kei-contacts-apple/src/contact.rs
Normal file
97
_primitives/_rust/kei-contacts-apple/src/contact.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! [`AppleContact`] — normalised contact returned by the CardDAV client.
|
||||
|
||||
use kei_social_store::people::Person;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single contact entry from iCloud CardDAV, normalised to flat strings.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AppleContact {
|
||||
/// vCard UID property (stable identifier).
|
||||
pub uid: String,
|
||||
/// Formatted name (FN property).
|
||||
pub display_name: String,
|
||||
/// Given name (first component of N property).
|
||||
pub given_name: String,
|
||||
/// Family name (second component of N property).
|
||||
pub family_name: String,
|
||||
/// All EMAIL values from the vCard.
|
||||
pub emails: Vec<String>,
|
||||
/// All TEL values from the vCard.
|
||||
pub phones: Vec<String>,
|
||||
/// ORG property (first component).
|
||||
pub organization: String,
|
||||
/// NOTE property.
|
||||
pub note: String,
|
||||
/// Original vCard text (verbatim).
|
||||
pub raw_vcard: String,
|
||||
}
|
||||
|
||||
impl AppleContact {
|
||||
/// Map to a [`kei_social_store::people::Person`] for store ingestion.
|
||||
///
|
||||
/// - `name` — `display_name`, falling back to `"{given} {family}"`.
|
||||
/// - `email` — first email or empty string.
|
||||
/// - `source` — `"apple:{uid}"`.
|
||||
/// - `id` / `created_at` / `updated_at` — zero (assigned by the store).
|
||||
pub fn to_person(&self) -> Person {
|
||||
let name = if !self.display_name.is_empty() {
|
||||
self.display_name.clone()
|
||||
} else {
|
||||
format!("{} {}", self.given_name, self.family_name)
|
||||
};
|
||||
let email = self.emails.first().cloned().unwrap_or_default();
|
||||
Person {
|
||||
id: 0,
|
||||
name,
|
||||
email,
|
||||
handle: String::new(),
|
||||
role: String::new(),
|
||||
organization: self.organization.clone(),
|
||||
source: format!("apple:{}", self.uid),
|
||||
bio: self.note.clone(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn to_person_maps_correctly() {
|
||||
let c = AppleContact {
|
||||
uid: "abc-123".to_string(),
|
||||
display_name: "Denis Parfionovich".to_string(),
|
||||
given_name: "Denis".to_string(),
|
||||
family_name: "Parfionovich".to_string(),
|
||||
emails: vec!["denis@example.com".to_string()],
|
||||
phones: vec!["+1234567890".to_string()],
|
||||
organization: "KeiSei Labs".to_string(),
|
||||
note: "founder".to_string(),
|
||||
raw_vcard: String::new(),
|
||||
};
|
||||
let p = c.to_person();
|
||||
assert_eq!(p.name, "Denis Parfionovich");
|
||||
assert_eq!(p.email, "denis@example.com");
|
||||
assert_eq!(p.source, "apple:abc-123");
|
||||
assert_eq!(p.organization, "KeiSei Labs");
|
||||
assert_eq!(p.bio, "founder");
|
||||
assert_eq!(p.id, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_person_fallback_name() {
|
||||
let c = AppleContact {
|
||||
uid: "x".to_string(),
|
||||
given_name: "Alice".to_string(),
|
||||
family_name: "Smith".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let p = c.to_person();
|
||||
assert_eq!(p.name, "Alice Smith");
|
||||
}
|
||||
}
|
||||
25
_primitives/_rust/kei-contacts-apple/src/error.rs
Normal file
25
_primitives/_rust/kei-contacts-apple/src/error.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! `ContactsError` — typed error variants for the iCloud CardDAV client.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by [`crate::ICloudCardDavClient`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ContactsError {
|
||||
/// Network or non-auth HTTP error.
|
||||
#[error("http error: {0}")]
|
||||
Http(String),
|
||||
|
||||
/// Authentication failure (401 or 403 from iCloud).
|
||||
#[error("auth error: {0}")]
|
||||
Auth(String),
|
||||
|
||||
/// XML or response body parsing failure.
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
|
||||
/// vCard content could not be parsed into a contact.
|
||||
#[error("invalid vcard: {0}")]
|
||||
InvalidVCard(String),
|
||||
}
|
||||
35
_primitives/_rust/kei-contacts-apple/src/lib.rs
Normal file
35
_primitives/_rust/kei-contacts-apple/src/lib.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! `kei-contacts-apple` — CardDAV client for iCloud Contacts.
|
||||
//!
|
||||
//! Authenticates with **Apple ID + app-specific password** (HTTP Basic Auth).
|
||||
//! Does NOT use Sign in with Apple / OAuth — that scope does not cover contacts.
|
||||
//!
|
||||
//! # Quick start
|
||||
//! ```no_run
|
||||
//! # async fn example() -> Result<(), kei_contacts_apple::ContactsError> {
|
||||
//! 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();
|
||||
//! println!("{} <{}>", person.name, person.email);
|
||||
//! }
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod client;
|
||||
pub mod contact;
|
||||
pub mod error;
|
||||
pub mod vcard;
|
||||
pub(crate) mod xml;
|
||||
|
||||
pub use client::ICloudCardDavClient;
|
||||
pub use contact::AppleContact;
|
||||
pub use error::ContactsError;
|
||||
140
_primitives/_rust/kei-contacts-apple/src/vcard.rs
Normal file
140
_primitives/_rust/kei-contacts-apple/src/vcard.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! Minimal vCard 3.0 / 4.0 parser.
|
||||
//!
|
||||
//! # Limitations (MVP)
|
||||
//! - **Line-folding** (continuation lines starting with a single space or tab)
|
||||
//! is **not** handled. Such lines are treated as separate malformed lines and
|
||||
//! silently skipped. This covers the vast majority of iCloud-generated vCards.
|
||||
//! - Only a fixed set of properties (FN, N, EMAIL, TEL, ORG, NOTE, UID) is extracted.
|
||||
//! - Property parameters (e.g. `TYPE=INTERNET`) are stripped; only the value is kept.
|
||||
//! - Multi-valued ORG (e.g. `ORG:Company;Department`) uses the first segment.
|
||||
|
||||
use crate::contact::AppleContact;
|
||||
use crate::error::ContactsError;
|
||||
|
||||
/// Parse a single vCard text into an [`AppleContact`].
|
||||
///
|
||||
/// `text` must be the content of one vCard (between `BEGIN:VCARD` and `END:VCARD`
|
||||
/// inclusive).
|
||||
pub fn parse_vcard(text: &str) -> Result<AppleContact, ContactsError> {
|
||||
let mut contact = AppleContact {
|
||||
raw_vcard: text.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
// Skip continuation lines (line-folding, not handled in MVP).
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
continue;
|
||||
}
|
||||
let Some((key_full, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
// Strip parameters: key_full may be "EMAIL;TYPE=INTERNET" → key = "EMAIL"
|
||||
let key = key_full
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or(key_full)
|
||||
.to_ascii_uppercase();
|
||||
let value = value.trim();
|
||||
apply_property(&key, value, &mut contact);
|
||||
}
|
||||
|
||||
if contact.uid.is_empty() && contact.display_name.is_empty() {
|
||||
return Err(ContactsError::InvalidVCard(
|
||||
"no UID or FN found in vCard".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(contact)
|
||||
}
|
||||
|
||||
fn apply_property(key: &str, value: &str, c: &mut AppleContact) {
|
||||
match key {
|
||||
"FN" => c.display_name = value.to_string(),
|
||||
"UID" => c.uid = value.to_string(),
|
||||
"NOTE" => c.note = value.to_string(),
|
||||
"EMAIL" => {
|
||||
if !value.is_empty() {
|
||||
c.emails.push(value.to_string());
|
||||
}
|
||||
}
|
||||
"TEL" => {
|
||||
if !value.is_empty() {
|
||||
c.phones.push(value.to_string());
|
||||
}
|
||||
}
|
||||
"N" => parse_n(value, c),
|
||||
"ORG" => {
|
||||
// ORG may be "Company;Department;..." — take first segment.
|
||||
let org = value.split(';').next().unwrap_or(value);
|
||||
if !org.is_empty() {
|
||||
c.organization = org.to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse vCard N property: `family;given;additional;prefix;suffix`
|
||||
fn parse_n(value: &str, c: &mut AppleContact) {
|
||||
let mut parts = value.splitn(5, ';');
|
||||
c.family_name = parts.next().unwrap_or("").to_string();
|
||||
c.given_name = parts.next().unwrap_or("").to_string();
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SIMPLE_VCARD: &str = "\
|
||||
BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
FN:Denis Parfionovich\r\n\
|
||||
N:Parfionovich;Denis;;;\r\n\
|
||||
EMAIL;TYPE=INTERNET:denis@example.com\r\n\
|
||||
ORG:KeiSei Labs\r\n\
|
||||
NOTE:hand-written note\r\n\
|
||||
UID:abc-123\r\n\
|
||||
END:VCARD\r\n";
|
||||
|
||||
#[test]
|
||||
fn parse_simple_vcard() {
|
||||
let c = parse_vcard(SIMPLE_VCARD).expect("should parse");
|
||||
assert_eq!(c.display_name, "Denis Parfionovich");
|
||||
assert_eq!(c.given_name, "Denis");
|
||||
assert_eq!(c.family_name, "Parfionovich");
|
||||
assert_eq!(c.emails, vec!["denis@example.com"]);
|
||||
assert_eq!(c.organization, "KeiSei Labs");
|
||||
assert_eq!(c.note, "hand-written note");
|
||||
assert_eq!(c.uid, "abc-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multi_email_vcard() {
|
||||
let text = "\
|
||||
BEGIN:VCARD\r\n\
|
||||
VERSION:3.0\r\n\
|
||||
FN:Alice Smith\r\n\
|
||||
UID:uid-alice\r\n\
|
||||
EMAIL;TYPE=INTERNET:alice@work.com\r\n\
|
||||
EMAIL;TYPE=HOME:alice@home.com\r\n\
|
||||
TEL;TYPE=CELL:+1234567890\r\n\
|
||||
END:VCARD\r\n";
|
||||
let c = parse_vcard(text).expect("should parse");
|
||||
assert_eq!(c.emails.len(), 2);
|
||||
assert!(c.emails.contains(&"alice@work.com".to_string()));
|
||||
assert!(c.emails.contains(&"alice@home.com".to_string()));
|
||||
assert_eq!(c.phones, vec!["+1234567890"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_vcard_returns_error() {
|
||||
let text = "NOTACARD:yes\r\n";
|
||||
assert!(parse_vcard(text).is_err());
|
||||
}
|
||||
}
|
||||
45
_primitives/_rust/kei-contacts-apple/src/xml.rs
Normal file
45
_primitives/_rust/kei-contacts-apple/src/xml.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! XML helpers for CardDAV multistatus responses.
|
||||
|
||||
use crate::contact::AppleContact;
|
||||
use crate::error::ContactsError;
|
||||
use crate::vcard::parse_vcard;
|
||||
use regex::Regex;
|
||||
use tracing::debug;
|
||||
|
||||
/// Build the CardDAV `addressbook-query` REPORT XML body.
|
||||
pub(crate) fn addressbook_query_xml() -> String {
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop><D:getetag/><C:address-data/></D:prop>
|
||||
<C:filter/>
|
||||
</C:addressbook-query>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Extract embedded vCards from a CardDAV multistatus XML response.
|
||||
///
|
||||
/// Parsing is done with a simple regex to avoid pulling in a full XML crate.
|
||||
pub(crate) fn extract_contacts_from_multistatus(
|
||||
xml: &str,
|
||||
) -> Result<Vec<AppleContact>, ContactsError> {
|
||||
let re = Regex::new(r"(?si)<[^:>]*:?address-data[^>]*>(.*?)</[^:>]*:?address-data>")
|
||||
.map_err(|e| ContactsError::Parse(e.to_string()))?;
|
||||
|
||||
let mut contacts = Vec::new();
|
||||
for cap in re.captures_iter(xml) {
|
||||
let raw = cap.get(1).map(|m| m.as_str()).unwrap_or("").trim();
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match parse_vcard(raw) {
|
||||
Ok(c) => contacts.push(c),
|
||||
Err(e) => {
|
||||
debug!("skipping unparseable vCard: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(contacts)
|
||||
}
|
||||
31
_primitives/_rust/kei-contacts-google/Cargo.toml
Normal file
31
_primitives/_rust/kei-contacts-google/Cargo.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
[package]
|
||||
name = "kei-contacts-google"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Thin client for Google People API. Consumes an access_token from kei-auth-google; does NOT perform OAuth itself."
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "kei_contacts_google"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros"] }
|
||||
tracing = "0.1"
|
||||
kei-social-store = { path = "../kei-social-store" }
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
[package.metadata.keisei]
|
||||
maturity = "alpha"
|
||||
backend = "google"
|
||||
description = "Google People API thin client (token-in, Vec<GoogleContact>-out)"
|
||||
authors = ["Denis Parfionovich <parfionovich@keilab.io>"]
|
||||
35
_primitives/_rust/kei-contacts-google/README.md
Normal file
35
_primitives/_rust/kei-contacts-google/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# kei-contacts-google
|
||||
|
||||
Thin client for the [Google People API v1](https://developers.google.com/people).
|
||||
|
||||
Consumes a pre-acquired OAuth2 access token (e.g. from `kei-auth-google`).
|
||||
Does **not** perform OAuth itself.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use kei_contacts_google::GooglePeopleClient;
|
||||
use kei_social_store::{Store, people::add_person};
|
||||
|
||||
async fn sync_contacts(token: String, store: &Store) -> anyhow::Result<()> {
|
||||
let client = GooglePeopleClient::new(token);
|
||||
let contacts = client.list_connections().await?;
|
||||
for c in &contacts {
|
||||
let person = c.to_person();
|
||||
add_person(store, &person)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Output shape
|
||||
|
||||
Each `GoogleContact` maps to `kei_social_store::Person`:
|
||||
|
||||
| Person field | Source |
|
||||
|----------------|----------------------------------|
|
||||
| `name` | `display_name` or `given family` |
|
||||
| `email` | first email address |
|
||||
| `organization` | first organization name |
|
||||
| `source` | `"google:{resource_name}"` |
|
||||
| `bio` | first biography value |
|
||||
181
_primitives/_rust/kei-contacts-google/src/client.rs
Normal file
181
_primitives/_rust/kei-contacts-google/src/client.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! [`GooglePeopleClient`] — thin HTTP wrapper around Google People API v1.
|
||||
|
||||
use crate::contact::GoogleContact;
|
||||
use crate::error::ContactsError;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://people.googleapis.com";
|
||||
const PERSON_FIELDS: &str =
|
||||
"names,emailAddresses,phoneNumbers,organizations,biographies";
|
||||
const PAGE_SIZE: u32 = 200;
|
||||
|
||||
/// Thin client for the Google People API.
|
||||
///
|
||||
/// Expects a valid OAuth2 access token. Does NOT perform OAuth itself;
|
||||
/// obtain the token from `kei-auth-google` or similar.
|
||||
pub struct GooglePeopleClient {
|
||||
access_token: String,
|
||||
base_url: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl GooglePeopleClient {
|
||||
/// Construct a client with the given access token and the production base URL.
|
||||
pub fn new(access_token: String) -> Self {
|
||||
Self {
|
||||
access_token,
|
||||
base_url: DEFAULT_BASE_URL.to_string(),
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the base URL (useful for wiremock tests).
|
||||
pub fn with_base_url(mut self, url: String) -> Self {
|
||||
self.base_url = url;
|
||||
self
|
||||
}
|
||||
|
||||
/// Fetch the authenticated user's contacts (first page only, ≤ 200).
|
||||
///
|
||||
/// # TODO
|
||||
/// Pagination via `nextPageToken` is not yet implemented. For users
|
||||
/// with > 200 contacts only the first 200 are returned.
|
||||
pub async fn list_connections(&self) -> Result<Vec<GoogleContact>, ContactsError> {
|
||||
let url = format!(
|
||||
"{}/v1/people/me/connections?personFields={}&pageSize={}",
|
||||
self.base_url, PERSON_FIELDS, PAGE_SIZE
|
||||
);
|
||||
debug!(%url, "GET people/me/connections");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.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)
|
||||
}
|
||||
}
|
||||
|
||||
// ── internal deserialization types ───────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConnectionsResponse {
|
||||
connections: Option<Vec<Connection>>,
|
||||
// next_page_token intentionally ignored (TODO: pagination)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Connection {
|
||||
resource_name: Option<String>,
|
||||
names: Option<Vec<Name>>,
|
||||
email_addresses: Option<Vec<EmailAddress>>,
|
||||
phone_numbers: Option<Vec<PhoneNumber>>,
|
||||
organizations: Option<Vec<OrgEntry>>,
|
||||
biographies: Option<Vec<Biography>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Name {
|
||||
display_name: Option<String>,
|
||||
given_name: Option<String>,
|
||||
family_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmailAddress {
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PhoneNumber {
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OrgEntry {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Biography {
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
92
_primitives/_rust/kei-contacts-google/src/contact.rs
Normal file
92
_primitives/_rust/kei-contacts-google/src/contact.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! [`GoogleContact`] — normalised contact returned by the People API.
|
||||
|
||||
use kei_social_store::people::Person;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single contact entry from the Google People API, normalised to flat strings.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct GoogleContact {
|
||||
/// E.g. `"people/c123456"` — stable identifier from the People API.
|
||||
pub resource_name: String,
|
||||
pub display_name: String,
|
||||
pub given_name: String,
|
||||
pub family_name: String,
|
||||
/// All email addresses reported by the API.
|
||||
pub emails: Vec<String>,
|
||||
/// All phone numbers reported by the API.
|
||||
pub phones: Vec<String>,
|
||||
/// Primary organization name (first entry).
|
||||
pub organization: String,
|
||||
/// First biography/note.
|
||||
pub bio: String,
|
||||
}
|
||||
|
||||
impl GoogleContact {
|
||||
/// Map to a [`kei_social_store::people::Person`] ready for store ingestion.
|
||||
///
|
||||
/// - `name` — `display_name`, falling back to `"{given} {family}"`.
|
||||
/// - `email` — first email or empty string.
|
||||
/// - `source` — `"google:{resource_name}"`.
|
||||
/// - `id` / `created_at` / `updated_at` — zero (assigned by the store on insert).
|
||||
pub fn to_person(&self) -> Person {
|
||||
let name = if !self.display_name.is_empty() {
|
||||
self.display_name.clone()
|
||||
} else {
|
||||
format!("{} {}", self.given_name, self.family_name)
|
||||
};
|
||||
let email = self.emails.first().cloned().unwrap_or_default();
|
||||
Person {
|
||||
id: 0,
|
||||
name,
|
||||
email,
|
||||
handle: String::new(),
|
||||
role: String::new(),
|
||||
organization: self.organization.clone(),
|
||||
source: format!("google:{}", self.resource_name),
|
||||
bio: self.bio.clone(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn to_person_maps_correctly() {
|
||||
let c = GoogleContact {
|
||||
resource_name: "people/c99".to_string(),
|
||||
display_name: "Alice Smith".to_string(),
|
||||
given_name: "Alice".to_string(),
|
||||
family_name: "Smith".to_string(),
|
||||
emails: vec!["alice@example.com".to_string()],
|
||||
phones: vec!["+1-555-0100".to_string()],
|
||||
organization: "ACME Corp".to_string(),
|
||||
bio: "Engineer".to_string(),
|
||||
};
|
||||
let p = c.to_person();
|
||||
assert_eq!(p.name, "Alice Smith");
|
||||
assert_eq!(p.email, "alice@example.com");
|
||||
assert_eq!(p.source, "google:people/c99");
|
||||
assert_eq!(p.organization, "ACME Corp");
|
||||
assert_eq!(p.bio, "Engineer");
|
||||
assert_eq!(p.id, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_person_fallback_name() {
|
||||
let c = GoogleContact {
|
||||
resource_name: "people/c1".to_string(),
|
||||
display_name: String::new(),
|
||||
given_name: "Bob".to_string(),
|
||||
family_name: "Jones".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let p = c.to_person();
|
||||
assert_eq!(p.name, "Bob Jones");
|
||||
}
|
||||
}
|
||||
21
_primitives/_rust/kei-contacts-google/src/error.rs
Normal file
21
_primitives/_rust/kei-contacts-google/src/error.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! `ContactsError` — typed error variants for the Google People API client.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by [`crate::GooglePeopleClient`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ContactsError {
|
||||
/// Network or non-auth HTTP error.
|
||||
#[error("http error: {0}")]
|
||||
Http(String),
|
||||
|
||||
/// JSON deserialization failure.
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
|
||||
/// Google People API returned 401 — token expired or invalid.
|
||||
#[error("auth error: token expired or invalid")]
|
||||
Auth(String),
|
||||
}
|
||||
28
_primitives/_rust/kei-contacts-google/src/lib.rs
Normal file
28
_primitives/_rust/kei-contacts-google/src/lib.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! `kei-contacts-google` — thin client for Google People API v1.
|
||||
//!
|
||||
//! Expects an OAuth2 access token already acquired (e.g. from `kei-auth-google`).
|
||||
//! Does NOT perform OAuth itself.
|
||||
//!
|
||||
//! # Quick start
|
||||
//! ```no_run
|
||||
//! # async fn example() -> Result<(), kei_contacts_google::ContactsError> {
|
||||
//! let token = std::env::var("GOOGLE_ACCESS_TOKEN").unwrap();
|
||||
//! let client = kei_contacts_google::GooglePeopleClient::new(token);
|
||||
//! let contacts = client.list_connections().await?;
|
||||
//! for c in &contacts {
|
||||
//! let person = c.to_person();
|
||||
//! println!("{} <{}>", person.name, person.email);
|
||||
//! }
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod client;
|
||||
pub mod contact;
|
||||
pub mod error;
|
||||
|
||||
pub use client::GooglePeopleClient;
|
||||
pub use contact::GoogleContact;
|
||||
pub use error::ContactsError;
|
||||
73
_primitives/_rust/kei-contacts-google/tests/connections.rs
Normal file
73
_primitives/_rust/kei-contacts-google/tests/connections.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright 2026 <author org>
|
||||
//! Integration tests for `GooglePeopleClient` against a wiremock server.
|
||||
|
||||
use kei_contacts_google::{ContactsError, GooglePeopleClient};
|
||||
use wiremock::matchers::{header_exists, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const SAMPLE_RESPONSE: &str = r#"{
|
||||
"connections": [
|
||||
{
|
||||
"resourceName": "people/c111",
|
||||
"names": [{"displayName": "Alice Smith", "givenName": "Alice", "familyName": "Smith"}],
|
||||
"emailAddresses": [{"value": "alice@example.com"}],
|
||||
"phoneNumbers": [{"value": "+1-555-0101"}],
|
||||
"organizations": [{"name": "ACME"}],
|
||||
"biographies": [{"value": "Engineer"}]
|
||||
},
|
||||
{
|
||||
"resourceName": "people/c222",
|
||||
"names": [{"displayName": "Bob Jones", "givenName": "Bob", "familyName": "Jones"}],
|
||||
"emailAddresses": [{"value": "bob@example.com"}, {"value": "bob2@example.com"}],
|
||||
"phoneNumbers": [],
|
||||
"organizations": [],
|
||||
"biographies": []
|
||||
}
|
||||
],
|
||||
"nextPageToken": "tok123"
|
||||
}"#;
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_connections_parses_real_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/people/me/connections"))
|
||||
.and(header_exists("Authorization"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_RESPONSE))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GooglePeopleClient::new("fake-token".to_string())
|
||||
.with_base_url(server.uri());
|
||||
let contacts = client.list_connections().await.expect("should succeed");
|
||||
|
||||
assert_eq!(contacts.len(), 2);
|
||||
let alice = &contacts[0];
|
||||
assert_eq!(alice.resource_name, "people/c111");
|
||||
assert_eq!(alice.display_name, "Alice Smith");
|
||||
assert_eq!(alice.emails, vec!["alice@example.com"]);
|
||||
assert_eq!(alice.phones, vec!["+1-555-0101"]);
|
||||
assert_eq!(alice.organization, "ACME");
|
||||
assert_eq!(alice.bio, "Engineer");
|
||||
|
||||
let bob = &contacts[1];
|
||||
assert_eq!(bob.emails.len(), 2);
|
||||
assert_eq!(bob.phones.len(), 0);
|
||||
assert_eq!(bob.organization, "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_error_on_401() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v1/people/me/connections"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GooglePeopleClient::new("expired-token".to_string())
|
||||
.with_base_url(server.uri());
|
||||
let err = client.list_connections().await.expect_err("should fail");
|
||||
assert!(matches!(err, ContactsError::Auth(_)));
|
||||
}
|
||||
Loading…
Reference in a new issue