diff --git a/src/openhuman/channels/providers/telegram/channel.rs b/src/openhuman/channels/providers/telegram/channel.rs index 8d7ff79fb..ace3743bf 100644 --- a/src/openhuman/channels/providers/telegram/channel.rs +++ b/src/openhuman/channels/providers/telegram/channel.rs @@ -1,2065 +1,19 @@ //! Telegram Bot API channel implementation. - -use super::attachments::{ - is_http_url, parse_attachment_markers, parse_path_only_attachment, TelegramAttachment, - TelegramAttachmentKind, -}; -use super::text::{ - split_message_for_telegram, strip_tool_call_tags, TELEGRAM_BIND_COMMAND, - TELEGRAM_MAX_MESSAGE_LENGTH, -}; -use crate::core::event_bus::{publish_global, DomainEvent}; -use crate::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage}; -use crate::openhuman::config::{Config, StreamMode}; -use crate::openhuman::security::pairing::PairingGuard; -use anyhow::Context; -use async_trait::async_trait; -use directories::UserDirs; -use parking_lot::Mutex; -use reqwest::multipart::{Form, Part}; -use std::collections::{HashSet, VecDeque}; -use std::path::Path; -use std::sync::{Arc, RwLock}; -use std::time::Duration; -use tokio::fs; - -const TELEGRAM_RECENT_UPDATE_CACHE_SIZE: usize = 4096; - -struct TelegramTypingTask { - recipient: String, - handle: tokio::task::JoinHandle<()>, -} - -#[derive(Default)] -struct TelegramUpdateWindow { - max_seen_update_id: i64, - recent_order: VecDeque, - recent_lookup: HashSet, -} - -#[derive(Debug, Clone)] -struct TelegramReactionEvent { - sender: String, - reply_target: String, - target_message_id: String, - emoji: String, -} - -/// Telegram channel — long-polls the Bot API for updates -pub struct TelegramChannel { - bot_token: String, - allowed_users: Arc>>, - pairing: Option, - client: reqwest::Client, - typing_handle: Mutex>, - stream_mode: StreamMode, - draft_update_interval_ms: u64, - silent_streaming: bool, - last_draft_edit: Mutex>, - mention_only: bool, - bot_username: Mutex>, - recent_updates: Mutex, -} - -impl TelegramChannel { - pub fn new(bot_token: String, allowed_users: Vec, mention_only: bool) -> Self { - let normalized_allowed = Self::normalize_allowed_users(allowed_users); - let pairing = if normalized_allowed.is_empty() { - let guard = PairingGuard::new(true, &[]); - if let Some(code) = guard.pairing_code() { - println!(" 🔐 Telegram pairing required. One-time bind code: {code}"); - println!(" Send `{TELEGRAM_BIND_COMMAND} ` from your Telegram account."); - } - Some(guard) - } else { - None - }; - - Self { - bot_token, - allowed_users: Arc::new(RwLock::new(normalized_allowed)), - pairing, - client: reqwest::Client::new(), - stream_mode: StreamMode::Off, - draft_update_interval_ms: 1000, - silent_streaming: true, - last_draft_edit: Mutex::new(std::collections::HashMap::new()), - typing_handle: Mutex::new(None), - mention_only, - bot_username: Mutex::new(None), - recent_updates: Mutex::new(TelegramUpdateWindow::default()), - } - } - - /// Configure streaming mode for progressive draft updates. - pub fn with_streaming( - mut self, - stream_mode: StreamMode, - draft_update_interval_ms: u64, - silent_streaming: bool, - ) -> Self { - self.stream_mode = stream_mode; - self.draft_update_interval_ms = draft_update_interval_ms; - self.silent_streaming = silent_streaming; - self - } - - /// Parse reply_target into (chat_id, optional thread_id). - fn parse_reply_target(reply_target: &str) -> (String, Option) { - if let Some((chat_id, thread_id)) = reply_target.split_once(':') { - (chat_id.to_string(), Some(thread_id.to_string())) - } else { - (reply_target.to_string(), None) - } - } - - fn parse_message_id(value: Option<&str>) -> Option { - value.and_then(|raw| raw.trim().parse::().ok()) - } - - fn typing_body_for_recipient(recipient: &str) -> serde_json::Value { - let (chat_id, thread_id) = Self::parse_reply_target(recipient); - let mut body = serde_json::json!({ - "chat_id": chat_id, - "action": "typing" - }); - if let Some(thread_id) = thread_id { - body["message_thread_id"] = serde_json::Value::String(thread_id); - } - body - } - - async fn telegram_api_ok(resp: reqwest::Response) -> bool { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - if !status.is_success() { - tracing::warn!(status = ?status, body, "Telegram API request failed"); - return false; - } - - match serde_json::from_str::(&body) { - Ok(payload) => { - if payload - .get("ok") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - true - } else { - let error_code = payload - .get("error_code") - .and_then(serde_json::Value::as_i64) - .unwrap_or_default(); - let description = payload - .get("description") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown Telegram API error"); - tracing::warn!( - status = ?status, - error_code, - description, - body, - "Telegram API responded with ok=false" - ); - false - } - } - Err(error) => { - tracing::warn!( - status = ?status, - %error, - body, - "Telegram API returned non-JSON body" - ); - false - } - } - } - - async fn send_typing_action_once(&self, recipient: &str) { - tracing::info!(recipient, "Telegram typing action attempt"); - let body = Self::typing_body_for_recipient(recipient); - let has_thread_id = body.get("message_thread_id").is_some(); - match self - .http_client() - .post(self.api_url("sendChatAction")) - .json(&body) - .send() - .await - { - Ok(resp) => { - if Self::telegram_api_ok(resp).await { - tracing::info!(recipient, "Telegram typing action sent"); - return; - } - tracing::warn!(recipient, "Telegram typing action rejected"); - - // Some chats can reject thread-scoped chat actions; retry plain chat_id once. - if has_thread_id { - let (chat_id, _) = Self::parse_reply_target(recipient); - let fallback_body = serde_json::json!({ - "chat_id": chat_id, - "action": "typing" - }); - match self - .http_client() - .post(self.api_url("sendChatAction")) - .json(&fallback_body) - .send() - .await - { - Ok(fallback_resp) => { - if Self::telegram_api_ok(fallback_resp).await { - tracing::warn!( - recipient, - "Telegram typing action accepted after removing message_thread_id" - ); - } else { - tracing::warn!( - recipient, - "Telegram typing fallback (without message_thread_id) rejected" - ); - } - } - Err(fallback_error) => { - tracing::warn!( - recipient, - %fallback_error, - "Telegram typing fallback request failed" - ); - } - } - } - } - Err(error) => { - tracing::warn!(recipient, %error, "Telegram typing action request failed"); - } - } - } - - fn track_update_id(&self, update_id: i64) -> bool { - let mut window = self.recent_updates.lock(); - if window.recent_lookup.contains(&update_id) { - tracing::debug!( - update_id, - "Telegram update dedupe hit: duplicate update skipped" - ); - return false; - } - - if update_id < window.max_seen_update_id { - tracing::debug!( - update_id, - max_seen = window.max_seen_update_id, - "Telegram update ordering safeguard: stale update skipped" - ); - return false; - } - - if update_id > window.max_seen_update_id { - window.max_seen_update_id = update_id; - } - - window.recent_lookup.insert(update_id); - window.recent_order.push_back(update_id); - if window.recent_order.len() > TELEGRAM_RECENT_UPDATE_CACHE_SIZE { - if let Some(evicted) = window.recent_order.pop_front() { - window.recent_lookup.remove(&evicted); - } - } - true - } - - fn http_client(&self) -> reqwest::Client { - crate::openhuman::config::build_runtime_proxy_client("channel.telegram") - } - - fn normalize_identity(value: &str) -> String { - value.trim().trim_start_matches('@').to_string() - } - - fn normalize_allowed_users(allowed_users: Vec) -> Vec { - allowed_users - .into_iter() - .map(|entry| Self::normalize_identity(&entry)) - .filter(|entry| !entry.is_empty()) - .collect() - } - - async fn load_config_without_env() -> anyhow::Result { - let home = UserDirs::new() - .map(|u| u.home_dir().to_path_buf()) - .context("Could not find home directory")?; - let openhuman_dir = home.join(".openhuman"); - let config_path = openhuman_dir.join("config.toml"); - - let contents = fs::read_to_string(&config_path) - .await - .with_context(|| format!("Failed to read config file: {}", config_path.display()))?; - let mut config: Config = toml::from_str(&contents) - .context("Failed to parse config file for Telegram binding")?; - config.config_path = config_path; - config.workspace_dir = openhuman_dir.join("workspace"); - Ok(config) - } - - async fn persist_allowed_identity(&self, identity: &str) -> anyhow::Result<()> { - let mut config = Self::load_config_without_env().await?; - let Some(telegram) = config.channels_config.telegram.as_mut() else { - anyhow::bail!("Telegram channel config is missing in config.toml"); - }; - - let normalized = Self::normalize_identity(identity); - if normalized.is_empty() { - anyhow::bail!("Cannot persist empty Telegram identity"); - } - - if !telegram.allowed_users.iter().any(|u| u == &normalized) { - telegram.allowed_users.push(normalized); - config - .save() - .await - .context("Failed to persist Telegram allowlist to config.toml")?; - } - - Ok(()) - } - - fn add_allowed_identity_runtime(&self, identity: &str) { - let normalized = Self::normalize_identity(identity); - if normalized.is_empty() { - return; - } - if let Ok(mut users) = self.allowed_users.write() { - if !users.iter().any(|u| u == &normalized) { - users.push(normalized); - } - } - } - - fn extract_bind_code(text: &str) -> Option<&str> { - let mut parts = text.split_whitespace(); - let command = parts.next()?; - let base_command = command.split('@').next().unwrap_or(command); - if base_command != TELEGRAM_BIND_COMMAND { - return None; - } - parts.next().map(str::trim).filter(|code| !code.is_empty()) - } - - fn pairing_code_active(&self) -> bool { - self.pairing - .as_ref() - .and_then(PairingGuard::pairing_code) - .is_some() - } - - fn api_url(&self, method: &str) -> String { - format!("https://api.telegram.org/bot{}/{method}", self.bot_token) - } - - /// Clears Bot API webhook mode so `getUpdates` long polling can run. - async fn delete_webhook_for_long_polling(&self) -> bool { - let url = self.api_url("deleteWebhook"); - let body = serde_json::json!({ "drop_pending_updates": false }); - tracing::info!( - "[telegram] deleteWebhook: enabling getUpdates polling (drop_pending_updates=false)" - ); - match self.http_client().post(&url).json(&body).send().await { - Ok(resp) => Self::telegram_api_ok(resp).await, - Err(e) => { - tracing::warn!(error = %e, "[telegram] deleteWebhook HTTP request failed"); - false - } - } - } - - async fn fetch_bot_username(&self) -> anyhow::Result { - let resp = self.http_client().get(self.api_url("getMe")).send().await?; - - if !resp.status().is_success() { - anyhow::bail!("Failed to fetch bot info: {}", resp.status()); - } - - let data: serde_json::Value = resp.json().await?; - let username = data - .get("result") - .and_then(|r| r.get("username")) - .and_then(|u| u.as_str()) - .context("Bot username not found in response")?; - - Ok(username.to_string()) - } - - async fn get_bot_username(&self) -> Option { - { - let cache = self.bot_username.lock(); - if let Some(ref username) = *cache { - return Some(username.clone()); - } - } - - match self.fetch_bot_username().await { - Ok(username) => { - let mut cache = self.bot_username.lock(); - *cache = Some(username.clone()); - Some(username) - } - Err(e) => { - tracing::warn!("Failed to fetch bot username: {e}"); - None - } - } - } - - fn is_telegram_username_char(ch: char) -> bool { - ch.is_ascii_alphanumeric() || ch == '_' - } - - fn find_bot_mention_spans(text: &str, bot_username: &str) -> Vec<(usize, usize)> { - let bot_username = bot_username.trim_start_matches('@'); - if bot_username.is_empty() { - return Vec::new(); - } - - let mut spans = Vec::new(); - - for (at_idx, ch) in text.char_indices() { - if ch != '@' { - continue; - } - - if at_idx > 0 { - let prev = text[..at_idx].chars().next_back().unwrap_or(' '); - if Self::is_telegram_username_char(prev) { - continue; - } - } - - let username_start = at_idx + 1; - let mut username_end = username_start; - - for (rel_idx, candidate_ch) in text[username_start..].char_indices() { - if Self::is_telegram_username_char(candidate_ch) { - username_end = username_start + rel_idx + candidate_ch.len_utf8(); - } else { - break; - } - } - - if username_end == username_start { - continue; - } - - let mention_username = &text[username_start..username_end]; - if mention_username.eq_ignore_ascii_case(bot_username) { - spans.push((at_idx, username_end)); - } - } - - spans - } - - fn contains_bot_mention(text: &str, bot_username: &str) -> bool { - !Self::find_bot_mention_spans(text, bot_username).is_empty() - } - - fn normalize_incoming_content(text: &str, bot_username: &str) -> Option { - let spans = Self::find_bot_mention_spans(text, bot_username); - if spans.is_empty() { - let normalized = text.split_whitespace().collect::>().join(" "); - return (!normalized.is_empty()).then_some(normalized); - } - - let mut normalized = String::with_capacity(text.len()); - let mut cursor = 0; - for (start, end) in spans { - normalized.push_str(&text[cursor..start]); - cursor = end; - } - normalized.push_str(&text[cursor..]); - - let normalized = normalized.split_whitespace().collect::>().join(" "); - (!normalized.is_empty()).then_some(normalized) - } - - fn is_group_message(message: &serde_json::Value) -> bool { - message - .get("chat") - .and_then(|c| c.get("type")) - .and_then(|t| t.as_str()) - .map(|t| t == "group" || t == "supergroup") - .unwrap_or(false) - } - - fn is_user_allowed(&self, username: &str) -> bool { - let identity = Self::normalize_identity(username); - self.allowed_users - .read() - .map(|users| { - users - .iter() - .any(|u| u == "*" || u.eq_ignore_ascii_case(&identity)) - }) - .unwrap_or(false) - } - - fn is_any_user_allowed<'a, I>(&self, identities: I) -> bool - where - I: IntoIterator, - { - identities.into_iter().any(|id| self.is_user_allowed(id)) - } - - async fn handle_unauthorized_message(&self, update: &serde_json::Value) { - let Some(message) = update.get("message") else { - return; - }; - - let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else { - return; - }; - - let username_opt = message - .get("from") - .and_then(|from| from.get("username")) - .and_then(serde_json::Value::as_str); - let username = username_opt.unwrap_or("unknown"); - let normalized_username = Self::normalize_identity(username); - - let sender_id = message - .get("from") - .and_then(|from| from.get("id")) - .and_then(serde_json::Value::as_i64); - let sender_id_str = sender_id.map(|id| id.to_string()); - let normalized_sender_id = sender_id_str.as_deref().map(Self::normalize_identity); - - let chat_id = message - .get("chat") - .and_then(|chat| chat.get("id")) - .and_then(serde_json::Value::as_i64) - .map(|id| id.to_string()); - - let Some(chat_id) = chat_id else { - tracing::warn!("Telegram: missing chat_id in message, skipping"); - return; - }; - - let mut identities = vec![normalized_username.as_str()]; - if let Some(ref id) = normalized_sender_id { - identities.push(id.as_str()); - } - - if self.is_any_user_allowed(identities.iter().copied()) { - return; - } - - if let Some(code) = Self::extract_bind_code(text) { - if let Some(pairing) = self.pairing.as_ref() { - match pairing.try_pair(code).await { - Ok(Some(_token)) => { - let bind_identity = normalized_sender_id.clone().or_else(|| { - if normalized_username.is_empty() || normalized_username == "unknown" { - None - } else { - Some(normalized_username.clone()) - } - }); - - if let Some(identity) = bind_identity { - self.add_allowed_identity_runtime(&identity); - match self.persist_allowed_identity(&identity).await { - Ok(()) => { - let _ = self - .send(&SendMessage::new( - "✅ Telegram account bound successfully. You can talk to OpenHuman now.", - &chat_id, - )) - .await; - tracing::info!( - "Telegram: paired and allowlisted identity={identity}" - ); - } - Err(e) => { - tracing::error!( - "Telegram: failed to persist allowlist after bind: {e}" - ); - let _ = self - .send(&SendMessage::new( - "âš ī¸ Bound for this runtime, but failed to persist config. Access may be lost after restart; check config file permissions.", - &chat_id, - )) - .await; - } - } - } else { - let _ = self - .send(&SendMessage::new( - "❌ Could not identify your Telegram account. Ensure your account has a username or stable user ID, then retry.", - &chat_id, - )) - .await; - } - } - Ok(None) => { - let _ = self - .send(&SendMessage::new( - "❌ Invalid binding code. Ask operator for the latest code and retry.", - &chat_id, - )) - .await; - } - Err(lockout_secs) => { - let _ = self - .send(&SendMessage::new( - format!("âŗ Too many invalid attempts. Retry in {lockout_secs}s."), - &chat_id, - )) - .await; - } - } - } else { - let _ = self - .send(&SendMessage::new( - "â„šī¸ Telegram pairing is not active. Ask operator to update allowlist in config.toml.", - &chat_id, - )) - .await; - } - return; - } - - tracing::warn!( - "Telegram: ignoring message from unauthorized user: username={username}, sender_id={}. \ -Allowlist Telegram username (without '@') or numeric user ID.", - sender_id_str.as_deref().unwrap_or("unknown") - ); - - let _ = self - .send(&SendMessage::new( - "🔐 This bot requires operator approval.\n\nAsk the operator to approve the pairing in the web UI, then send your message again.".to_string(), - &chat_id, - )) - .await; - - if self.pairing_code_active() { - let _ = self - .send(&SendMessage::new( - "â„šī¸ If operator provides a one-time pairing code, you can also run `/bind `.", - &chat_id, - )) - .await; - } - } - - fn parse_update_message(&self, update: &serde_json::Value) -> Option { - let message = update - .get("message") - .or_else(|| update.get("edited_message"))?; - - let text = message.get("text").and_then(serde_json::Value::as_str)?; - - let username = message - .get("from") - .and_then(|from| from.get("username")) - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown") - .to_string(); - - let sender_id = message - .get("from") - .and_then(|from| from.get("id")) - .and_then(serde_json::Value::as_i64) - .map(|id| id.to_string()); - - let sender_identity = if username == "unknown" { - sender_id.clone().unwrap_or_else(|| "unknown".to_string()) - } else { - username.clone() - }; - - let mut identities = vec![username.as_str()]; - if let Some(id) = sender_id.as_deref() { - identities.push(id); - } - - if !self.is_any_user_allowed(identities.iter().copied()) { - tracing::debug!( - username = %username, - sender_id = sender_id.as_deref().unwrap_or("none"), - message_len = text.len(), - "[telegram] dropped message: sender not in allowed_users (unauthorized handler may reply)" - ); - return None; - } - - let is_group = Self::is_group_message(message); - if self.mention_only && is_group { - let bot_username = self.bot_username.lock(); - if let Some(ref bot_username) = *bot_username { - if !Self::contains_bot_mention(text, bot_username) { - return None; - } - } else { - return None; - } - } - - let chat_id = message - .get("chat") - .and_then(|chat| chat.get("id")) - .and_then(serde_json::Value::as_i64) - .map(|id| id.to_string())?; - - let message_id = message - .get("message_id") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - - // Extract thread/topic ID for forum support - let thread_id = message - .get("message_thread_id") - .and_then(serde_json::Value::as_i64) - .map(|id| id.to_string()); - - // reply_target: chat_id or chat_id:thread_id format - let reply_target = if let Some(tid) = thread_id { - format!("{}:{}", chat_id, tid) - } else { - chat_id.clone() - }; - - let replied_parent_message_id = message - .get("reply_to_message") - .and_then(|reply| reply.get("message_id")) - .and_then(serde_json::Value::as_i64) - .map(|id| id.to_string()); - - // Telegram "reply" targeting should point to the inbound message itself so the - // assistant response is visibly attached in chat. We still retain the inbound - // parent reference in logs for reply-context diagnostics. - let outbound_reply_to_message_id = Some(message_id.to_string()); - tracing::debug!( - chat_id, - message_id, - reply_to_parent = replied_parent_message_id.as_deref().unwrap_or("none"), - "Telegram inbound message parsed for reply mapping" - ); - - let content = if self.mention_only && is_group { - let bot_username = self.bot_username.lock(); - let bot_username = bot_username.as_ref()?; - Self::normalize_incoming_content(text, bot_username)? - } else { - text.to_string() - }; - - Some(ChannelMessage { - id: format!("telegram_{chat_id}_{message_id}"), - sender: sender_identity, - reply_target, - content, - channel: "telegram".to_string(), - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - thread_ts: outbound_reply_to_message_id, - }) - } - - fn parse_update_reaction(&self, update: &serde_json::Value) -> Option { - let reaction = update.get("message_reaction")?; - - let chat_id = reaction - .get("chat") - .and_then(|chat| chat.get("id")) - .and_then(serde_json::Value::as_i64) - .map(|id| id.to_string())?; - let message_id = reaction - .get("message_id") - .and_then(serde_json::Value::as_i64) - .map(|id| id.to_string())?; - let actor = reaction - .get("user") - .and_then(|user| user.get("username")) - .and_then(serde_json::Value::as_str) - .map(ToString::to_string) - .or_else(|| { - reaction - .get("user") - .and_then(|user| user.get("id")) - .and_then(serde_json::Value::as_i64) - .map(|id| id.to_string()) - }) - .unwrap_or_else(|| "unknown".to_string()); - - let user_id = reaction - .get("user") - .and_then(|user| user.get("id")) - .and_then(serde_json::Value::as_i64) - .map(|id| id.to_string()); - - let actor_allowed = self.is_user_allowed(&actor); - let user_id_allowed = user_id - .as_deref() - .is_some_and(|id| self.is_user_allowed(id)); - - if !(actor_allowed || user_id_allowed) { - tracing::debug!( - actor, - message_id, - "Telegram reaction ignored: actor is not allowlisted" - ); - return None; - } - - let emoji = reaction - .get("new_reaction") - .and_then(serde_json::Value::as_array) - .and_then(|arr| { - arr.iter().find_map(|entry| { - entry - .get("emoji") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string) - }) - })?; - - Some(TelegramReactionEvent { - sender: actor, - reply_target: chat_id, - target_message_id: message_id, - emoji, - }) - } - - async fn send_message_reaction( - &self, - chat_id: &str, - message_id: i64, - emoji: &str, - ) -> anyhow::Result { - let emoji = emoji.trim(); - if emoji.is_empty() { - return Ok(false); - } - - let body = serde_json::json!({ - "chat_id": chat_id, - "message_id": message_id, - "reaction": [ - { - "type": "emoji", - "emoji": emoji - } - ], - "is_big": false - }); - let resp = self - .http_client() - .post(self.api_url("setMessageReaction")) - .json(&body) - .send() - .await?; - if resp.status().is_success() { - publish_global(DomainEvent::ChannelReactionSent { - channel: "telegram".to_string(), - target_message_id: format!("telegram_{chat_id}_{message_id}"), - emoji: emoji.to_string(), - success: true, - }); - tracing::info!(chat_id, message_id, emoji, "Telegram reaction sent"); - return Ok(true); - } - - let status = resp.status(); - let err = resp.text().await.unwrap_or_default(); - tracing::warn!( - chat_id, - message_id, - emoji, - status = ?status, - error = err, - "Telegram reaction not applied; continuing without failure" - ); - publish_global(DomainEvent::ChannelReactionSent { - channel: "telegram".to_string(), - target_message_id: format!("telegram_{chat_id}_{message_id}"), - emoji: emoji.to_string(), - success: false, - }); - Ok(false) - } - - fn parse_reaction_marker(content: &str) -> (String, Option) { - // Marker format at the start of the message: [REACTION:😀] or [REACTION:😀|12345] - // The marker may be followed by a text reply: [REACTION:👍] Great point! - // Returns (remaining_text, Some(marker_inner)) or (original, None). - let trimmed = content.trim(); - let Some(rest) = trimmed.strip_prefix("[REACTION:") else { - return (content.to_string(), None); - }; - let Some(close_pos) = rest.find(']') else { - return (content.to_string(), None); - }; - let inner = rest[..close_pos].trim(); - if inner.is_empty() { - return (String::new(), None); - } - let remaining = rest[close_pos + 1..].trim().to_string(); - (remaining, Some(inner.to_string())) - } - - async fn send_text_chunks( - &self, - message: &str, - chat_id: &str, - thread_id: Option<&str>, - reply_to_message_id: Option, - disable_notification: bool, - ) -> anyhow::Result<()> { - let chunks = split_message_for_telegram(message); - - for (index, chunk) in chunks.iter().enumerate() { - let text = if chunks.len() > 1 { - if index == 0 { - format!("{chunk}\n\n(continues...)") - } else if index == chunks.len() - 1 { - format!("(continued)\n\n{chunk}") - } else { - format!("(continued)\n\n{chunk}\n\n(continues...)") - } - } else { - chunk.to_string() - }; - - let mut markdown_body = serde_json::json!({ - "chat_id": chat_id, - "text": text, - "parse_mode": "Markdown", - "disable_notification": disable_notification, - }); - - // Add message_thread_id for forum topic support - if let Some(tid) = thread_id { - markdown_body["message_thread_id"] = serde_json::Value::String(tid.to_string()); - } - if index == 0 { - if let Some(parent_id) = reply_to_message_id { - markdown_body["reply_to_message_id"] = serde_json::Value::from(parent_id); - } - } - - let markdown_resp = self - .http_client() - .post(self.api_url("sendMessage")) - .json(&markdown_body) - .send() - .await?; - - if markdown_resp.status().is_success() { - if index < chunks.len() - 1 { - tokio::time::sleep(Duration::from_millis(100)).await; - } - continue; - } - - let markdown_status = markdown_resp.status(); - let markdown_err = markdown_resp.text().await.unwrap_or_default(); - tracing::warn!( - status = ?markdown_status, - "Telegram sendMessage with Markdown failed; retrying without parse_mode" - ); - - let mut plain_body = serde_json::json!({ - "chat_id": chat_id, - "text": text, - "disable_notification": disable_notification, - }); - - // Add message_thread_id for forum topic support - if let Some(tid) = thread_id { - plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string()); - } - if index == 0 { - if let Some(parent_id) = reply_to_message_id { - plain_body["reply_to_message_id"] = serde_json::Value::from(parent_id); - } - } - let plain_resp = self - .http_client() - .post(self.api_url("sendMessage")) - .json(&plain_body) - .send() - .await?; - - if !plain_resp.status().is_success() { - let plain_status = plain_resp.status(); - let plain_err = plain_resp.text().await.unwrap_or_default(); - anyhow::bail!( - "Telegram sendMessage failed (markdown {}: {}; plain {}: {})", - markdown_status, - markdown_err, - plain_status, - plain_err - ); - } - - if index < chunks.len() - 1 { - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - - Ok(()) - } - - async fn send_media_by_url( - &self, - method: &str, - media_field: &str, - chat_id: &str, - thread_id: Option<&str>, - url: &str, - caption: Option<&str>, - ) -> anyhow::Result<()> { - let mut body = serde_json::json!({ - "chat_id": chat_id, - }); - body[media_field] = serde_json::Value::String(url.to_string()); - - if let Some(tid) = thread_id { - body["message_thread_id"] = serde_json::Value::String(tid.to_string()); - } - - if let Some(cap) = caption { - body["caption"] = serde_json::Value::String(cap.to_string()); - } - - let resp = self - .http_client() - .post(self.api_url(method)) - .json(&body) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await?; - anyhow::bail!("Telegram {method} by URL failed: {err}"); - } - - tracing::info!("Telegram {method} sent to {chat_id}: {url}"); - Ok(()) - } - - async fn send_attachment( - &self, - chat_id: &str, - thread_id: Option<&str>, - attachment: &TelegramAttachment, - ) -> anyhow::Result<()> { - let target = attachment.target.trim(); - - if is_http_url(target) { - return match attachment.kind { - TelegramAttachmentKind::Image => { - self.send_photo_by_url(chat_id, thread_id, target, None) - .await - } - TelegramAttachmentKind::Document => { - self.send_document_by_url(chat_id, thread_id, target, None) - .await - } - TelegramAttachmentKind::Video => { - self.send_video_by_url(chat_id, thread_id, target, None) - .await - } - TelegramAttachmentKind::Audio => { - self.send_audio_by_url(chat_id, thread_id, target, None) - .await - } - TelegramAttachmentKind::Voice => { - self.send_voice_by_url(chat_id, thread_id, target, None) - .await - } - }; - } - - let path = Path::new(target); - if !path.exists() { - anyhow::bail!("Telegram attachment path not found: {target}"); - } - - match attachment.kind { - TelegramAttachmentKind::Image => self.send_photo(chat_id, thread_id, path, None).await, - TelegramAttachmentKind::Document => { - self.send_document(chat_id, thread_id, path, None).await - } - TelegramAttachmentKind::Video => self.send_video(chat_id, thread_id, path, None).await, - TelegramAttachmentKind::Audio => self.send_audio(chat_id, thread_id, path, None).await, - TelegramAttachmentKind::Voice => self.send_voice(chat_id, thread_id, path, None).await, - } - } - - /// Send a document/file to a Telegram chat - pub async fn send_document( - &self, - chat_id: &str, - thread_id: Option<&str>, - file_path: &Path, - caption: Option<&str>, - ) -> anyhow::Result<()> { - let file_name = file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file"); - - let file_bytes = tokio::fs::read(file_path).await?; - let part = Part::bytes(file_bytes).file_name(file_name.to_string()); - - let mut form = Form::new() - .text("chat_id", chat_id.to_string()) - .part("document", part); - - if let Some(tid) = thread_id { - form = form.text("message_thread_id", tid.to_string()); - } - - if let Some(cap) = caption { - form = form.text("caption", cap.to_string()); - } - - let resp = self - .http_client() - .post(self.api_url("sendDocument")) - .multipart(form) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await?; - anyhow::bail!("Telegram sendDocument failed: {err}"); - } - - tracing::info!("Telegram document sent to {chat_id}: {file_name}"); - Ok(()) - } - - /// Send a document from bytes (in-memory) to a Telegram chat - pub async fn send_document_bytes( - &self, - chat_id: &str, - thread_id: Option<&str>, - file_bytes: Vec, - file_name: &str, - caption: Option<&str>, - ) -> anyhow::Result<()> { - let part = Part::bytes(file_bytes).file_name(file_name.to_string()); - - let mut form = Form::new() - .text("chat_id", chat_id.to_string()) - .part("document", part); - - if let Some(tid) = thread_id { - form = form.text("message_thread_id", tid.to_string()); - } - - if let Some(cap) = caption { - form = form.text("caption", cap.to_string()); - } - - let resp = self - .http_client() - .post(self.api_url("sendDocument")) - .multipart(form) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await?; - anyhow::bail!("Telegram sendDocument failed: {err}"); - } - - tracing::info!("Telegram document sent to {chat_id}: {file_name}"); - Ok(()) - } - - /// Send a photo to a Telegram chat - pub async fn send_photo( - &self, - chat_id: &str, - thread_id: Option<&str>, - file_path: &Path, - caption: Option<&str>, - ) -> anyhow::Result<()> { - let file_name = file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("photo.jpg"); - - let file_bytes = tokio::fs::read(file_path).await?; - let part = Part::bytes(file_bytes).file_name(file_name.to_string()); - - let mut form = Form::new() - .text("chat_id", chat_id.to_string()) - .part("photo", part); - - if let Some(tid) = thread_id { - form = form.text("message_thread_id", tid.to_string()); - } - - if let Some(cap) = caption { - form = form.text("caption", cap.to_string()); - } - - let resp = self - .http_client() - .post(self.api_url("sendPhoto")) - .multipart(form) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await?; - anyhow::bail!("Telegram sendPhoto failed: {err}"); - } - - tracing::info!("Telegram photo sent to {chat_id}: {file_name}"); - Ok(()) - } - - /// Send a photo from bytes (in-memory) to a Telegram chat - pub async fn send_photo_bytes( - &self, - chat_id: &str, - thread_id: Option<&str>, - file_bytes: Vec, - file_name: &str, - caption: Option<&str>, - ) -> anyhow::Result<()> { - let part = Part::bytes(file_bytes).file_name(file_name.to_string()); - - let mut form = Form::new() - .text("chat_id", chat_id.to_string()) - .part("photo", part); - - if let Some(tid) = thread_id { - form = form.text("message_thread_id", tid.to_string()); - } - - if let Some(cap) = caption { - form = form.text("caption", cap.to_string()); - } - - let resp = self - .http_client() - .post(self.api_url("sendPhoto")) - .multipart(form) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await?; - anyhow::bail!("Telegram sendPhoto failed: {err}"); - } - - tracing::info!("Telegram photo sent to {chat_id}: {file_name}"); - Ok(()) - } - - /// Send a video to a Telegram chat - pub async fn send_video( - &self, - chat_id: &str, - thread_id: Option<&str>, - file_path: &Path, - caption: Option<&str>, - ) -> anyhow::Result<()> { - let file_name = file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("video.mp4"); - - let file_bytes = tokio::fs::read(file_path).await?; - let part = Part::bytes(file_bytes).file_name(file_name.to_string()); - - let mut form = Form::new() - .text("chat_id", chat_id.to_string()) - .part("video", part); - - if let Some(tid) = thread_id { - form = form.text("message_thread_id", tid.to_string()); - } - - if let Some(cap) = caption { - form = form.text("caption", cap.to_string()); - } - - let resp = self - .http_client() - .post(self.api_url("sendVideo")) - .multipart(form) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await?; - anyhow::bail!("Telegram sendVideo failed: {err}"); - } - - tracing::info!("Telegram video sent to {chat_id}: {file_name}"); - Ok(()) - } - - /// Send an audio file to a Telegram chat - pub async fn send_audio( - &self, - chat_id: &str, - thread_id: Option<&str>, - file_path: &Path, - caption: Option<&str>, - ) -> anyhow::Result<()> { - let file_name = file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("audio.mp3"); - - let file_bytes = tokio::fs::read(file_path).await?; - let part = Part::bytes(file_bytes).file_name(file_name.to_string()); - - let mut form = Form::new() - .text("chat_id", chat_id.to_string()) - .part("audio", part); - - if let Some(tid) = thread_id { - form = form.text("message_thread_id", tid.to_string()); - } - - if let Some(cap) = caption { - form = form.text("caption", cap.to_string()); - } - - let resp = self - .http_client() - .post(self.api_url("sendAudio")) - .multipart(form) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await?; - anyhow::bail!("Telegram sendAudio failed: {err}"); - } - - tracing::info!("Telegram audio sent to {chat_id}: {file_name}"); - Ok(()) - } - - /// Send a voice message to a Telegram chat - pub async fn send_voice( - &self, - chat_id: &str, - thread_id: Option<&str>, - file_path: &Path, - caption: Option<&str>, - ) -> anyhow::Result<()> { - let file_name = file_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("voice.ogg"); - - let file_bytes = tokio::fs::read(file_path).await?; - let part = Part::bytes(file_bytes).file_name(file_name.to_string()); - - let mut form = Form::new() - .text("chat_id", chat_id.to_string()) - .part("voice", part); - - if let Some(tid) = thread_id { - form = form.text("message_thread_id", tid.to_string()); - } - - if let Some(cap) = caption { - form = form.text("caption", cap.to_string()); - } - - let resp = self - .http_client() - .post(self.api_url("sendVoice")) - .multipart(form) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await?; - anyhow::bail!("Telegram sendVoice failed: {err}"); - } - - tracing::info!("Telegram voice sent to {chat_id}: {file_name}"); - Ok(()) - } - - /// Send a file by URL (Telegram will download it) - pub async fn send_document_by_url( - &self, - chat_id: &str, - thread_id: Option<&str>, - url: &str, - caption: Option<&str>, - ) -> anyhow::Result<()> { - let mut body = serde_json::json!({ - "chat_id": chat_id, - "document": url - }); - - if let Some(tid) = thread_id { - body["message_thread_id"] = serde_json::Value::String(tid.to_string()); - } - - if let Some(cap) = caption { - body["caption"] = serde_json::Value::String(cap.to_string()); - } - - let resp = self - .http_client() - .post(self.api_url("sendDocument")) - .json(&body) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await?; - anyhow::bail!("Telegram sendDocument by URL failed: {err}"); - } - - tracing::info!("Telegram document (URL) sent to {chat_id}: {url}"); - Ok(()) - } - - /// Send a photo by URL (Telegram will download it) - pub async fn send_photo_by_url( - &self, - chat_id: &str, - thread_id: Option<&str>, - url: &str, - caption: Option<&str>, - ) -> anyhow::Result<()> { - let mut body = serde_json::json!({ - "chat_id": chat_id, - "photo": url - }); - - if let Some(tid) = thread_id { - body["message_thread_id"] = serde_json::Value::String(tid.to_string()); - } - - if let Some(cap) = caption { - body["caption"] = serde_json::Value::String(cap.to_string()); - } - - let resp = self - .http_client() - .post(self.api_url("sendPhoto")) - .json(&body) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await?; - anyhow::bail!("Telegram sendPhoto by URL failed: {err}"); - } - - tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}"); - Ok(()) - } - - /// Send a video by URL (Telegram will download it) - pub async fn send_video_by_url( - &self, - chat_id: &str, - thread_id: Option<&str>, - url: &str, - caption: Option<&str>, - ) -> anyhow::Result<()> { - self.send_media_by_url("sendVideo", "video", chat_id, thread_id, url, caption) - .await - } - - /// Send an audio file by URL (Telegram will download it) - pub async fn send_audio_by_url( - &self, - chat_id: &str, - thread_id: Option<&str>, - url: &str, - caption: Option<&str>, - ) -> anyhow::Result<()> { - self.send_media_by_url("sendAudio", "audio", chat_id, thread_id, url, caption) - .await - } - - /// Send a voice message by URL (Telegram will download it) - pub async fn send_voice_by_url( - &self, - chat_id: &str, - thread_id: Option<&str>, - url: &str, - caption: Option<&str>, - ) -> anyhow::Result<()> { - self.send_media_by_url("sendVoice", "voice", chat_id, thread_id, url, caption) - .await - } -} - -#[async_trait] -impl Channel for TelegramChannel { - fn name(&self) -> &str { - "telegram" - } - - fn supports_reactions(&self) -> bool { - true - } - - fn supports_draft_updates(&self) -> bool { - self.stream_mode != StreamMode::Off - } - - async fn send_draft(&self, message: &SendMessage) -> anyhow::Result> { - if self.stream_mode == StreamMode::Off { - return Ok(None); - } - - let (chat_id, thread_id) = Self::parse_reply_target(&message.recipient); - let parent_message_id = Self::parse_message_id(message.thread_ts.as_deref()); - let initial_text = if message.content.is_empty() { - "...".to_string() - } else { - message.content.clone() - }; - - let mut body = serde_json::json!({ - "chat_id": chat_id, - "text": initial_text, - "disable_notification": self.silent_streaming, - }); - if let Some(tid) = thread_id { - body["message_thread_id"] = serde_json::Value::String(tid.to_string()); - } - if let Some(parent_id) = parent_message_id { - body["reply_to_message_id"] = serde_json::Value::from(parent_id); - } - - let resp = self - .client - .post(self.api_url("sendMessage")) - .json(&body) - .send() - .await?; - - if !resp.status().is_success() { - let err = resp.text().await.unwrap_or_default(); - anyhow::bail!("Telegram sendMessage (draft) failed: {err}"); - } - - let resp_json: serde_json::Value = resp.json().await?; - let message_id = resp_json - .get("result") - .and_then(|r| r.get("message_id")) - .and_then(|id| id.as_i64()) - .map(|id| id.to_string()); - - self.last_draft_edit - .lock() - .insert(chat_id.to_string(), std::time::Instant::now()); - - Ok(message_id) - } - - async fn update_draft( - &self, - recipient: &str, - message_id: &str, - text: &str, - ) -> anyhow::Result<()> { - let (chat_id, _) = Self::parse_reply_target(recipient); - - // Rate-limit edits per chat - { - let last_edits = self.last_draft_edit.lock(); - if let Some(last_time) = last_edits.get(&chat_id) { - let elapsed = u64::try_from(last_time.elapsed().as_millis()).unwrap_or(u64::MAX); - if elapsed < self.draft_update_interval_ms { - return Ok(()); - } - } - } - - // Truncate to Telegram limit for mid-stream edits (UTF-8 safe) - let display_text = if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH { - let mut end = 0; - for (idx, ch) in text.char_indices() { - let next = idx + ch.len_utf8(); - if next > TELEGRAM_MAX_MESSAGE_LENGTH { - break; - } - end = next; - } - &text[..end] - } else { - text - }; - - let message_id_parsed = match message_id.parse::() { - Ok(id) => id, - Err(e) => { - tracing::warn!("Invalid Telegram message_id '{message_id}': {e}"); - return Ok(()); - } - }; - - let body = serde_json::json!({ - "chat_id": chat_id, - "message_id": message_id_parsed, - "text": display_text, - "disable_notification": self.silent_streaming, - }); - - let resp = self - .client - .post(self.api_url("editMessageText")) - .json(&body) - .send() - .await?; - - if resp.status().is_success() { - self.last_draft_edit - .lock() - .insert(chat_id.clone(), std::time::Instant::now()); - } else { - let status = resp.status(); - let err = resp.text().await.unwrap_or_default(); - tracing::debug!("Telegram editMessageText failed ({status}): {err}"); - } - - Ok(()) - } - - async fn finalize_draft( - &self, - recipient: &str, - message_id: &str, - text: &str, - thread_ts: Option<&str>, - ) -> anyhow::Result<()> { - let text = &strip_tool_call_tags(text); - let (chat_id, thread_id) = Self::parse_reply_target(recipient); - let parent_message_id = Self::parse_message_id(thread_ts); - - // Clean up rate-limit tracking for this chat - self.last_draft_edit.lock().remove(&chat_id); - - // If text exceeds limit, delete draft and send as chunked messages - if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH { - let msg_id = match message_id.parse::() { - Ok(id) => id, - Err(e) => { - tracing::warn!("Invalid Telegram message_id '{message_id}': {e}"); - return self - .send_text_chunks( - text, - &chat_id, - thread_id.as_deref(), - parent_message_id, - false, - ) - .await; - } - }; - - // Delete the draft - let _ = self - .client - .post(self.api_url("deleteMessage")) - .json(&serde_json::json!({ - "chat_id": chat_id, - "message_id": msg_id, - })) - .send() - .await; - - // Fall back to chunked send - return self - .send_text_chunks( - text, - &chat_id, - thread_id.as_deref(), - parent_message_id, - false, - ) - .await; - } - - let msg_id = match message_id.parse::() { - Ok(id) => id, - Err(e) => { - tracing::warn!("Invalid Telegram message_id '{message_id}': {e}"); - return self - .send_text_chunks( - text, - &chat_id, - thread_id.as_deref(), - parent_message_id, - false, - ) - .await; - } - }; - - // Try editing with Markdown formatting - let body = serde_json::json!({ - "chat_id": chat_id, - "message_id": msg_id, - "text": text, - "parse_mode": "Markdown", - }); - - let resp = self - .client - .post(self.api_url("editMessageText")) - .json(&body) - .send() - .await?; - - if resp.status().is_success() { - return Ok(()); - } - - // Markdown failed — retry without parse_mode - let plain_body = serde_json::json!({ - "chat_id": chat_id, - "message_id": msg_id, - "text": text, - }); - - let resp = self - .client - .post(self.api_url("editMessageText")) - .json(&plain_body) - .send() - .await?; - - if resp.status().is_success() { - return Ok(()); - } - - // Edit failed entirely — fall back to new message - tracing::warn!("Telegram finalize_draft edit failed; falling back to sendMessage"); - self.send_text_chunks( - text, - &chat_id, - thread_id.as_deref(), - parent_message_id, - false, - ) - .await - } - - async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { - // Strip tool_call tags before processing to prevent Markdown parsing failures - let content = strip_tool_call_tags(&message.content); - let parent_message_id = Self::parse_message_id(message.thread_ts.as_deref()); - - // Parse recipient: "chat_id" or "chat_id:thread_id" format - let (chat_id, thread_id) = match message.recipient.split_once(':') { - Some((chat, thread)) => (chat, Some(thread)), - None => (message.recipient.as_str(), None), - }; - - let (reactionless_content, reaction_marker) = Self::parse_reaction_marker(&content); - if let Some(reaction_marker) = reaction_marker.as_deref() { - let (emoji, explicit_target_id) = match reaction_marker.split_once('|') { - Some((emoji, target)) => (emoji.trim(), Self::parse_message_id(Some(target))), - None => (reaction_marker.trim(), None), - }; - let target_message_id = explicit_target_id.or(parent_message_id); - if let Some(target_id) = target_message_id { - let _ = self - .send_message_reaction(chat_id, target_id, emoji) - .await?; - tracing::debug!( - chat_id, - target_id, - emoji, - has_reply = !reactionless_content.is_empty(), - "[telegram] reaction sent; continuing to send reply text if present" - ); - } else { - tracing::warn!( - recipient = message.recipient, - marker = reaction_marker, - "[telegram] reaction marker ignored: missing target message id" - ); - } - // If no text follows the reaction marker, we are done. - if reactionless_content.trim().is_empty() { - return Ok(()); - } - } - - let (text_without_markers, attachments) = parse_attachment_markers(&reactionless_content); - - if !attachments.is_empty() { - if !text_without_markers.is_empty() { - self.send_text_chunks( - &text_without_markers, - chat_id, - thread_id, - parent_message_id, - false, - ) - .await?; - } - - for attachment in &attachments { - self.send_attachment(chat_id, thread_id, attachment).await?; - } - - return Ok(()); - } - - if let Some(attachment) = parse_path_only_attachment(&reactionless_content) { - self.send_attachment(chat_id, thread_id, &attachment) - .await?; - return Ok(()); - } - - self.send_text_chunks( - &reactionless_content, - chat_id, - thread_id, - parent_message_id, - false, - ) - .await - } - - async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { - let mut offset: i64 = 0; - - if self.mention_only { - let _ = self.get_bot_username().await; - } - - tracing::info!("Telegram channel listening for messages..."); - - loop { - if self.mention_only { - let missing_username = self.bot_username.lock().is_none(); - if missing_username { - let _ = self.get_bot_username().await; - } - } - - let url = self.api_url("getUpdates"); - let body = serde_json::json!({ - "offset": offset, - "timeout": 30, - "allowed_updates": ["message", "edited_message", "message_reaction"] - }); - - let resp = match self.http_client().post(&url).json(&body).send().await { - Ok(r) => r, - Err(e) => { - tracing::warn!("Telegram poll error: {e}"); - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - continue; - } - }; - - let data: serde_json::Value = match resp.json().await { - Ok(d) => d, - Err(e) => { - tracing::warn!("Telegram parse error: {e}"); - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - continue; - } - }; - - let ok = data - .get("ok") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true); - if !ok { - let error_code = data - .get("error_code") - .and_then(serde_json::Value::as_i64) - .unwrap_or_default(); - let description = data - .get("description") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown Telegram API error"); - - if error_code == 409 { - let webhook_blocks_polling = description.to_lowercase().contains("webhook"); - if webhook_blocks_polling { - tracing::warn!( - "[telegram] getUpdates conflict (409): webhook is active; calling deleteWebhook" - ); - if self.delete_webhook_for_long_polling().await { - tracing::info!("[telegram] deleteWebhook ok; retrying getUpdates"); - continue; - } - tracing::warn!("[telegram] deleteWebhook did not succeed; backing off"); - } else { - tracing::warn!( - "Telegram polling conflict (409): {description}. \ -Ensure only one `openhuman` process is using this bot token." - ); - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } else { - tracing::warn!( - "Telegram getUpdates API error (code={}): {description}", - error_code - ); - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - } - continue; - } - - if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) { - for update in results { - let update_id = update - .get("update_id") - .and_then(serde_json::Value::as_i64) - .unwrap_or_default(); - if update_id > 0 && !self.track_update_id(update_id) { - continue; - } - - // Advance offset past this update - if let Some(uid) = update.get("update_id").and_then(serde_json::Value::as_i64) { - offset = uid + 1; - } - - if let Some(reaction) = self.parse_update_reaction(update) { - tracing::info!( - sender = reaction.sender, - reply_target = reaction.reply_target, - target_message_id = reaction.target_message_id, - emoji = reaction.emoji, - "Telegram reaction received" - ); - publish_global(DomainEvent::ChannelReactionReceived { - channel: "telegram".to_string(), - sender: reaction.sender, - target_message_id: format!( - "telegram_{}_{}", - reaction.reply_target, reaction.target_message_id - ), - emoji: reaction.emoji, - }); - continue; - } - - let Some(msg) = self.parse_update_message(update) else { - self.handle_unauthorized_message(update).await; - continue; - }; - - if tx.send(msg).await.is_err() { - return Ok(()); - } - } - } - } - } - - async fn health_check(&self) -> bool { - let timeout_duration = Duration::from_secs(5); - - match tokio::time::timeout( - timeout_duration, - self.http_client().get(self.api_url("getMe")).send(), - ) - .await - { - Ok(Ok(resp)) => resp.status().is_success(), - Ok(Err(e)) => { - tracing::debug!("Telegram health check failed: {e}"); - false - } - Err(_) => { - tracing::debug!("Telegram health check timed out after 5s"); - false - } - } - } - - async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> { - tracing::info!(recipient, "Telegram start_typing invoked"); - // Emit immediately so short model turns still show "typingâ€Ļ" - self.send_typing_action_once(recipient).await; - - { - let guard = self.typing_handle.lock(); - if guard - .as_ref() - .is_some_and(|task| task.recipient == recipient) - { - return Ok(()); - } - } - self.stop_typing(recipient).await?; - - let client = self.http_client(); - let url = self.api_url("sendChatAction"); - let recipient_owned = recipient.to_string(); - let recipient_for_log = recipient_owned.clone(); - let body = Self::typing_body_for_recipient(recipient); - - let handle = tokio::spawn(async move { - loop { - match client.post(&url).json(&body).send().await { - Ok(resp) => { - if !Self::telegram_api_ok(resp).await { - tracing::warn!( - recipient = recipient_for_log, - "Telegram typing refresh rejected" - ); - } - } - Err(error) => { - tracing::warn!( - recipient = recipient_for_log, - %error, - "Telegram typing refresh request failed" - ); - } - } - // Telegram typing indicator expires after 5s; refresh at 4s - tokio::time::sleep(Duration::from_secs(4)).await; - } - }); - - let mut guard = self.typing_handle.lock(); - *guard = Some(TelegramTypingTask { - recipient: recipient_owned, - handle, - }); - - Ok(()) - } - - async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> { - tracing::info!("Telegram stop_typing invoked"); - let mut guard = self.typing_handle.lock(); - if let Some(task) = guard.take() { - task.handle.abort(); - } - Ok(()) - } -} +//! +//! This module is the orchestration entry point for the Telegram channel. +//! Implementation is split across sibling modules by concern: +//! +//! - [`super::channel_types`] — struct definition and private helper types +//! - [`super::channel_core`] — constructor, config, pairing/auth, API plumbing +//! - [`super::channel_recv`] — inbound parsing, allowlist checks, mention filtering +//! - [`super::channel_send`] — outbound text, media, reactions, attachments +//! - [`super::channel_ops`] — `Channel` trait impl (send/listen/draft/typing) + +// Re-export so that the `#[path = "channel_tests.rs"]` test module can reach +// `TelegramChannel` via `super::TelegramChannel`. +pub use super::channel_types::TelegramChannel; +#[cfg(test)] +pub(super) use super::channel_types::TelegramTypingTask; #[cfg(test)] #[path = "channel_tests.rs"] diff --git a/src/openhuman/channels/providers/telegram/channel_core.rs b/src/openhuman/channels/providers/telegram/channel_core.rs new file mode 100644 index 000000000..db6231ae6 --- /dev/null +++ b/src/openhuman/channels/providers/telegram/channel_core.rs @@ -0,0 +1,292 @@ +//! Telegram channel — constructor, configuration, auth/pairing, and API plumbing helpers. + +use super::channel_types::{ + TelegramChannel, TelegramUpdateWindow, TELEGRAM_RECENT_UPDATE_CACHE_SIZE, +}; +use super::text::TELEGRAM_BIND_COMMAND; +use crate::openhuman::config::{Config, StreamMode}; +use crate::openhuman::security::pairing::PairingGuard; +use anyhow::Context; +use directories::UserDirs; +use std::sync::{Arc, RwLock}; +use tokio::fs; + +impl TelegramChannel { + pub fn new(bot_token: String, allowed_users: Vec, mention_only: bool) -> Self { + let normalized_allowed = Self::normalize_allowed_users(allowed_users); + let pairing = if normalized_allowed.is_empty() { + let guard = PairingGuard::new(true, &[]); + if let Some(code) = guard.pairing_code() { + println!(" 🔐 Telegram pairing required. One-time bind code: {code}"); + println!(" Send `{TELEGRAM_BIND_COMMAND} ` from your Telegram account."); + } + Some(guard) + } else { + None + }; + + Self { + bot_token, + allowed_users: Arc::new(RwLock::new(normalized_allowed)), + pairing, + client: reqwest::Client::new(), + stream_mode: StreamMode::Off, + draft_update_interval_ms: 1000, + silent_streaming: true, + last_draft_edit: parking_lot::Mutex::new(std::collections::HashMap::new()), + typing_handle: parking_lot::Mutex::new(None), + mention_only, + bot_username: parking_lot::Mutex::new(None), + recent_updates: parking_lot::Mutex::new(TelegramUpdateWindow::default()), + } + } + + /// Configure streaming mode for progressive draft updates. + /// Configure streaming mode for progressive draft updates. + pub fn with_streaming( + mut self, + stream_mode: StreamMode, + draft_update_interval_ms: u64, + silent_streaming: bool, + ) -> Self { + self.stream_mode = stream_mode; + self.draft_update_interval_ms = draft_update_interval_ms; + self.silent_streaming = silent_streaming; + self + } + + /// Parse reply_target into (chat_id, optional thread_id). + pub(crate) fn parse_reply_target(reply_target: &str) -> (String, Option) { + if let Some((chat_id, thread_id)) = reply_target.split_once(':') { + (chat_id.to_string(), Some(thread_id.to_string())) + } else { + (reply_target.to_string(), None) + } + } + + pub(crate) fn parse_message_id(value: Option<&str>) -> Option { + value.and_then(|raw| raw.trim().parse::().ok()) + } + + pub(crate) fn http_client(&self) -> reqwest::Client { + crate::openhuman::config::build_runtime_proxy_client("channel.telegram") + } + + pub(crate) fn normalize_identity(value: &str) -> String { + value.trim().trim_start_matches('@').to_string() + } + + pub(crate) fn normalize_allowed_users(allowed_users: Vec) -> Vec { + allowed_users + .into_iter() + .map(|entry| Self::normalize_identity(&entry)) + .filter(|entry| !entry.is_empty()) + .collect() + } + + pub(crate) fn api_url(&self, method: &str) -> String { + format!("https://api.telegram.org/bot{}/{method}", self.bot_token) + } + + pub(crate) fn pairing_code_active(&self) -> bool { + self.pairing + .as_ref() + .and_then(PairingGuard::pairing_code) + .is_some() + } + + pub(crate) fn extract_bind_code(text: &str) -> Option<&str> { + let mut parts = text.split_whitespace(); + let command = parts.next()?; + let base_command = command.split('@').next().unwrap_or(command); + if base_command != TELEGRAM_BIND_COMMAND { + return None; + } + parts.next().map(str::trim).filter(|code| !code.is_empty()) + } + + pub(crate) fn track_update_id(&self, update_id: i64) -> bool { + let mut window = self.recent_updates.lock(); + if window.recent_lookup.contains(&update_id) { + tracing::debug!( + update_id, + "Telegram update dedupe hit: duplicate update skipped" + ); + return false; + } + + if update_id < window.max_seen_update_id { + tracing::debug!( + update_id, + max_seen = window.max_seen_update_id, + "Telegram update ordering safeguard: stale update skipped" + ); + return false; + } + + if update_id > window.max_seen_update_id { + window.max_seen_update_id = update_id; + } + + window.recent_lookup.insert(update_id); + window.recent_order.push_back(update_id); + if window.recent_order.len() > TELEGRAM_RECENT_UPDATE_CACHE_SIZE { + if let Some(evicted) = window.recent_order.pop_front() { + window.recent_lookup.remove(&evicted); + } + } + true + } + + /// Clears Bot API webhook mode so `getUpdates` long polling can run. + pub(crate) async fn delete_webhook_for_long_polling(&self) -> bool { + let url = self.api_url("deleteWebhook"); + let body = serde_json::json!({ "drop_pending_updates": false }); + tracing::info!( + "[telegram] deleteWebhook: enabling getUpdates polling (drop_pending_updates=false)" + ); + match self.http_client().post(&url).json(&body).send().await { + Ok(resp) => Self::telegram_api_ok(resp).await, + Err(e) => { + tracing::warn!(error = %e, "[telegram] deleteWebhook HTTP request failed"); + false + } + } + } + + pub(crate) async fn telegram_api_ok(resp: reqwest::Response) -> bool { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + tracing::warn!(status = ?status, body, "Telegram API request failed"); + return false; + } + + match serde_json::from_str::(&body) { + Ok(payload) => { + if payload + .get("ok") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + true + } else { + let error_code = payload + .get("error_code") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + let description = payload + .get("description") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown Telegram API error"); + tracing::warn!( + status = ?status, + error_code, + description, + body, + "Telegram API responded with ok=false" + ); + false + } + } + Err(error) => { + tracing::warn!( + status = ?status, + %error, + body, + "Telegram API returned non-JSON body" + ); + false + } + } + } + + pub(crate) async fn fetch_bot_username(&self) -> anyhow::Result { + let resp = self.http_client().get(self.api_url("getMe")).send().await?; + + if !resp.status().is_success() { + anyhow::bail!("Failed to fetch bot info: {}", resp.status()); + } + + let data: serde_json::Value = resp.json().await?; + let username = data + .get("result") + .and_then(|r| r.get("username")) + .and_then(|u| u.as_str()) + .context("Bot username not found in response")?; + + Ok(username.to_string()) + } + + pub(crate) async fn get_bot_username(&self) -> Option { + { + let cache = self.bot_username.lock(); + if let Some(ref username) = *cache { + return Some(username.clone()); + } + } + + match self.fetch_bot_username().await { + Ok(username) => { + let mut cache = self.bot_username.lock(); + *cache = Some(username.clone()); + Some(username) + } + Err(e) => { + tracing::warn!("Failed to fetch bot username: {e}"); + None + } + } + } + + async fn load_config_without_env() -> anyhow::Result { + let home = UserDirs::new() + .map(|u| u.home_dir().to_path_buf()) + .context("Could not find home directory")?; + let openhuman_dir = home.join(".openhuman"); + let config_path = openhuman_dir.join("config.toml"); + + let contents = fs::read_to_string(&config_path) + .await + .with_context(|| format!("Failed to read config file: {}", config_path.display()))?; + let mut config: Config = toml::from_str(&contents) + .context("Failed to parse config file for Telegram binding")?; + config.config_path = config_path; + config.workspace_dir = openhuman_dir.join("workspace"); + Ok(config) + } + + pub(crate) async fn persist_allowed_identity(&self, identity: &str) -> anyhow::Result<()> { + let mut config = Self::load_config_without_env().await?; + let Some(telegram) = config.channels_config.telegram.as_mut() else { + anyhow::bail!("Telegram channel config is missing in config.toml"); + }; + + let normalized = Self::normalize_identity(identity); + if normalized.is_empty() { + anyhow::bail!("Cannot persist empty Telegram identity"); + } + + if !telegram.allowed_users.iter().any(|u| u == &normalized) { + telegram.allowed_users.push(normalized); + config + .save() + .await + .context("Failed to persist Telegram allowlist to config.toml")?; + } + + Ok(()) + } + + pub(crate) fn add_allowed_identity_runtime(&self, identity: &str) { + let normalized = Self::normalize_identity(identity); + if normalized.is_empty() { + return; + } + if let Ok(mut users) = self.allowed_users.write() { + if !users.iter().any(|u| u == &normalized) { + users.push(normalized); + } + } + } +} diff --git a/src/openhuman/channels/providers/telegram/channel_ops.rs b/src/openhuman/channels/providers/telegram/channel_ops.rs new file mode 100644 index 000000000..cf57c68fe --- /dev/null +++ b/src/openhuman/channels/providers/telegram/channel_ops.rs @@ -0,0 +1,523 @@ +//! Telegram channel — `Channel` trait implementation: send, listen, draft streaming, typing. + +use super::attachments::{parse_attachment_markers, parse_path_only_attachment}; +use super::channel_types::{TelegramChannel, TelegramTypingTask}; +use super::text::{strip_tool_call_tags, TELEGRAM_MAX_MESSAGE_LENGTH}; +use crate::core::event_bus::{publish_global, DomainEvent}; +use crate::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage}; +use crate::openhuman::config::StreamMode; +use async_trait::async_trait; +use std::time::Duration; + +#[async_trait] +impl Channel for TelegramChannel { + fn name(&self) -> &str { + "telegram" + } + + fn supports_reactions(&self) -> bool { + true + } + + fn supports_draft_updates(&self) -> bool { + self.stream_mode != StreamMode::Off + } + + async fn send_draft(&self, message: &SendMessage) -> anyhow::Result> { + if self.stream_mode == StreamMode::Off { + return Ok(None); + } + + let (chat_id, thread_id) = Self::parse_reply_target(&message.recipient); + let parent_message_id = Self::parse_message_id(message.thread_ts.as_deref()); + let initial_text = if message.content.is_empty() { + "...".to_string() + } else { + message.content.clone() + }; + + let mut body = serde_json::json!({ + "chat_id": chat_id, + "text": initial_text, + }); + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + if let Some(parent_id) = parent_message_id { + body["reply_to_message_id"] = serde_json::Value::from(parent_id); + } + + let resp = self + .client + .post(self.api_url("sendMessage")) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("Telegram sendMessage (draft) failed: {err}"); + } + + let resp_json: serde_json::Value = resp.json().await?; + let message_id = resp_json + .get("result") + .and_then(|r| r.get("message_id")) + .and_then(|id| id.as_i64()) + .map(|id| id.to_string()); + + self.last_draft_edit + .lock() + .insert(chat_id.to_string(), std::time::Instant::now()); + + Ok(message_id) + } + + async fn update_draft( + &self, + recipient: &str, + message_id: &str, + text: &str, + ) -> anyhow::Result<()> { + let (chat_id, _) = Self::parse_reply_target(recipient); + + // Rate-limit edits per chat + { + let last_edits = self.last_draft_edit.lock(); + if let Some(last_time) = last_edits.get(&chat_id) { + let elapsed = u64::try_from(last_time.elapsed().as_millis()).unwrap_or(u64::MAX); + if elapsed < self.draft_update_interval_ms { + return Ok(()); + } + } + } + + // Truncate to Telegram limit for mid-stream edits (UTF-8 safe) + let display_text = if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH { + let mut end = 0; + for (idx, ch) in text.char_indices() { + let next = idx + ch.len_utf8(); + if next > TELEGRAM_MAX_MESSAGE_LENGTH { + break; + } + end = next; + } + &text[..end] + } else { + text + }; + + let message_id_parsed = match message_id.parse::() { + Ok(id) => id, + Err(e) => { + tracing::warn!("Invalid Telegram message_id '{message_id}': {e}"); + return Ok(()); + } + }; + + let body = serde_json::json!({ + "chat_id": chat_id, + "message_id": message_id_parsed, + "text": display_text, + }); + + let resp = self + .client + .post(self.api_url("editMessageText")) + .json(&body) + .send() + .await?; + + if resp.status().is_success() { + self.last_draft_edit + .lock() + .insert(chat_id.clone(), std::time::Instant::now()); + } else { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + tracing::debug!("Telegram editMessageText failed ({status}): {err}"); + } + + Ok(()) + } + + async fn finalize_draft( + &self, + recipient: &str, + message_id: &str, + text: &str, + thread_ts: Option<&str>, + ) -> anyhow::Result<()> { + let text = &strip_tool_call_tags(text); + let (chat_id, thread_id) = Self::parse_reply_target(recipient); + let parent_message_id = Self::parse_message_id(thread_ts); + + // Clean up rate-limit tracking for this chat + self.last_draft_edit.lock().remove(&chat_id); + + // If text exceeds limit, delete draft and send as chunked messages + if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH { + let msg_id = match message_id.parse::() { + Ok(id) => id, + Err(e) => { + tracing::warn!("Invalid Telegram message_id '{message_id}': {e}"); + return self + .send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id) + .await; + } + }; + + // Delete the draft + let _ = self + .client + .post(self.api_url("deleteMessage")) + .json(&serde_json::json!({ + "chat_id": chat_id, + "message_id": msg_id, + })) + .send() + .await; + + // Fall back to chunked send + return self + .send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id) + .await; + } + + let msg_id = match message_id.parse::() { + Ok(id) => id, + Err(e) => { + tracing::warn!("Invalid Telegram message_id '{message_id}': {e}"); + return self + .send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id) + .await; + } + }; + + // Try editing with Markdown formatting + let body = serde_json::json!({ + "chat_id": chat_id, + "message_id": msg_id, + "text": text, + "parse_mode": "Markdown", + }); + + let resp = self + .client + .post(self.api_url("editMessageText")) + .json(&body) + .send() + .await?; + + if resp.status().is_success() { + return Ok(()); + } + + // Markdown failed — retry without parse_mode + let plain_body = serde_json::json!({ + "chat_id": chat_id, + "message_id": msg_id, + "text": text, + }); + + let resp = self + .client + .post(self.api_url("editMessageText")) + .json(&plain_body) + .send() + .await?; + + if resp.status().is_success() { + return Ok(()); + } + + // Edit failed entirely — fall back to new message + tracing::warn!("Telegram finalize_draft edit failed; falling back to sendMessage"); + self.send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id) + .await + } + + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + // Strip tool_call tags before processing to prevent Markdown parsing failures + let content = strip_tool_call_tags(&message.content); + let parent_message_id = Self::parse_message_id(message.thread_ts.as_deref()); + + // Parse recipient: "chat_id" or "chat_id:thread_id" format + let (chat_id, thread_id) = match message.recipient.split_once(':') { + Some((chat, thread)) => (chat, Some(thread)), + None => (message.recipient.as_str(), None), + }; + + let (reactionless_content, reaction_marker) = Self::parse_reaction_marker(&content); + if let Some(reaction_marker) = reaction_marker.as_deref() { + let (emoji, explicit_target_id) = match reaction_marker.split_once('|') { + Some((emoji, target)) => (emoji.trim(), Self::parse_message_id(Some(target))), + None => (reaction_marker.trim(), None), + }; + let target_message_id = explicit_target_id.or(parent_message_id); + if let Some(target_id) = target_message_id { + let _ = self + .send_message_reaction(chat_id, target_id, emoji) + .await?; + tracing::debug!( + chat_id, + target_id, + emoji, + has_reply = !reactionless_content.is_empty(), + "[telegram] reaction sent; continuing to send reply text if present" + ); + } else { + tracing::warn!( + recipient = message.recipient, + marker = reaction_marker, + "[telegram] reaction marker ignored: missing target message id" + ); + } + // If no text follows the reaction marker, we are done. + if reactionless_content.trim().is_empty() { + return Ok(()); + } + } + + let (text_without_markers, attachments) = parse_attachment_markers(&reactionless_content); + + if !attachments.is_empty() { + if !text_without_markers.is_empty() { + self.send_text_chunks(&text_without_markers, chat_id, thread_id, parent_message_id) + .await?; + } + + for attachment in &attachments { + self.send_attachment(chat_id, thread_id, attachment).await?; + } + + return Ok(()); + } + + if let Some(attachment) = parse_path_only_attachment(&reactionless_content) { + self.send_attachment(chat_id, thread_id, &attachment) + .await?; + return Ok(()); + } + + self.send_text_chunks(&reactionless_content, chat_id, thread_id, parent_message_id) + .await + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + let mut offset: i64 = 0; + + if self.mention_only { + let _ = self.get_bot_username().await; + } + + tracing::info!("Telegram channel listening for messages..."); + + loop { + if self.mention_only { + let missing_username = self.bot_username.lock().is_none(); + if missing_username { + let _ = self.get_bot_username().await; + } + } + + let url = self.api_url("getUpdates"); + let body = serde_json::json!({ + "offset": offset, + "timeout": 30, + "allowed_updates": ["message", "edited_message", "message_reaction"] + }); + + let resp = match self.http_client().post(&url).json(&body).send().await { + Ok(r) => r, + Err(e) => { + tracing::warn!("Telegram poll error: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + }; + + let data: serde_json::Value = match resp.json().await { + Ok(d) => d, + Err(e) => { + tracing::warn!("Telegram parse error: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + }; + + let ok = data + .get("ok") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + if !ok { + let error_code = data + .get("error_code") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + let description = data + .get("description") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown Telegram API error"); + + if error_code == 409 { + let webhook_blocks_polling = description.to_lowercase().contains("webhook"); + if webhook_blocks_polling { + tracing::warn!( + "[telegram] getUpdates conflict (409): webhook is active; calling deleteWebhook" + ); + if self.delete_webhook_for_long_polling().await { + tracing::info!("[telegram] deleteWebhook ok; retrying getUpdates"); + continue; + } + tracing::warn!("[telegram] deleteWebhook did not succeed; backing off"); + } else { + tracing::warn!( + "Telegram polling conflict (409): {description}. \ +Ensure only one `openhuman` process is using this bot token." + ); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } else { + tracing::warn!( + "Telegram getUpdates API error (code={}): {description}", + error_code + ); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + continue; + } + + if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) { + for update in results { + let update_id = update + .get("update_id") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + if update_id > 0 && !self.track_update_id(update_id) { + continue; + } + + // Advance offset past this update + if let Some(uid) = update.get("update_id").and_then(serde_json::Value::as_i64) { + offset = uid + 1; + } + + if let Some(reaction) = self.parse_update_reaction(update) { + tracing::info!( + sender = reaction.sender, + reply_target = reaction.reply_target, + target_message_id = reaction.target_message_id, + emoji = reaction.emoji, + "Telegram reaction received" + ); + publish_global(DomainEvent::ChannelReactionReceived { + channel: "telegram".to_string(), + sender: reaction.sender, + target_message_id: format!( + "telegram_{}_{}", + reaction.reply_target, reaction.target_message_id + ), + emoji: reaction.emoji, + }); + continue; + } + + let Some(msg) = self.parse_update_message(update) else { + self.handle_unauthorized_message(update).await; + continue; + }; + + if tx.send(msg).await.is_err() { + return Ok(()); + } + } + } + } + } + + async fn health_check(&self) -> bool { + let timeout_duration = Duration::from_secs(5); + + match tokio::time::timeout( + timeout_duration, + self.http_client().get(self.api_url("getMe")).send(), + ) + .await + { + Ok(Ok(resp)) => resp.status().is_success(), + Ok(Err(e)) => { + tracing::debug!("Telegram health check failed: {e}"); + false + } + Err(_) => { + tracing::debug!("Telegram health check timed out after 5s"); + false + } + } + } + + async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> { + tracing::info!(recipient, "Telegram start_typing invoked"); + // Emit immediately so short model turns still show "typingâ€Ļ" + self.send_typing_action_once(recipient).await; + + { + let guard = self.typing_handle.lock(); + if guard + .as_ref() + .is_some_and(|task| task.recipient == recipient) + { + return Ok(()); + } + } + self.stop_typing(recipient).await?; + + let client = self.http_client(); + let url = self.api_url("sendChatAction"); + let recipient_owned = recipient.to_string(); + let recipient_for_log = recipient_owned.clone(); + let body = Self::typing_body_for_recipient(recipient); + + let handle = tokio::spawn(async move { + loop { + match client.post(&url).json(&body).send().await { + Ok(resp) => { + if !Self::telegram_api_ok(resp).await { + tracing::warn!( + recipient = recipient_for_log, + "Telegram typing refresh rejected" + ); + } + } + Err(error) => { + tracing::warn!( + recipient = recipient_for_log, + %error, + "Telegram typing refresh request failed" + ); + } + } + // Telegram typing indicator expires after 5s; refresh at 4s + tokio::time::sleep(Duration::from_secs(4)).await; + } + }); + + let mut guard = self.typing_handle.lock(); + *guard = Some(TelegramTypingTask { + recipient: recipient_owned, + handle, + }); + + Ok(()) + } + + async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> { + tracing::info!("Telegram stop_typing invoked"); + let mut guard = self.typing_handle.lock(); + if let Some(task) = guard.take() { + task.handle.abort(); + } + Ok(()) + } +} diff --git a/src/openhuman/channels/providers/telegram/channel_recv.rs b/src/openhuman/channels/providers/telegram/channel_recv.rs new file mode 100644 index 000000000..a3cd6a7f3 --- /dev/null +++ b/src/openhuman/channels/providers/telegram/channel_recv.rs @@ -0,0 +1,508 @@ +//! Telegram channel — inbound message/reaction parsing, allowlist checks, mention filtering, +//! unauthorized-message handling, and typing-action helpers. + +use super::channel_types::{TelegramChannel, TelegramReactionEvent}; +use crate::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage}; + +impl TelegramChannel { + pub(crate) fn typing_body_for_recipient(recipient: &str) -> serde_json::Value { + let (chat_id, thread_id) = Self::parse_reply_target(recipient); + let mut body = serde_json::json!({ + "chat_id": chat_id, + "action": "typing" + }); + if let Some(thread_id) = thread_id { + body["message_thread_id"] = serde_json::Value::String(thread_id); + } + body + } + + pub(crate) async fn send_typing_action_once(&self, recipient: &str) { + tracing::info!(recipient, "Telegram typing action attempt"); + let body = Self::typing_body_for_recipient(recipient); + let has_thread_id = body.get("message_thread_id").is_some(); + match self + .http_client() + .post(self.api_url("sendChatAction")) + .json(&body) + .send() + .await + { + Ok(resp) => { + if Self::telegram_api_ok(resp).await { + tracing::info!(recipient, "Telegram typing action sent"); + return; + } + tracing::warn!(recipient, "Telegram typing action rejected"); + + // Some chats can reject thread-scoped chat actions; retry plain chat_id once. + if has_thread_id { + let (chat_id, _) = Self::parse_reply_target(recipient); + let fallback_body = serde_json::json!({ + "chat_id": chat_id, + "action": "typing" + }); + match self + .http_client() + .post(self.api_url("sendChatAction")) + .json(&fallback_body) + .send() + .await + { + Ok(fallback_resp) => { + if Self::telegram_api_ok(fallback_resp).await { + tracing::warn!( + recipient, + "Telegram typing action accepted after removing message_thread_id" + ); + } else { + tracing::warn!( + recipient, + "Telegram typing fallback (without message_thread_id) rejected" + ); + } + } + Err(fallback_error) => { + tracing::warn!( + recipient, + %fallback_error, + "Telegram typing fallback request failed" + ); + } + } + } + } + Err(error) => { + tracing::warn!(recipient, %error, "Telegram typing action request failed"); + } + } + } + + pub(crate) fn is_telegram_username_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' + } + + pub(crate) fn find_bot_mention_spans(text: &str, bot_username: &str) -> Vec<(usize, usize)> { + let bot_username = bot_username.trim_start_matches('@'); + if bot_username.is_empty() { + return Vec::new(); + } + + let mut spans = Vec::new(); + + for (at_idx, ch) in text.char_indices() { + if ch != '@' { + continue; + } + + if at_idx > 0 { + let prev = text[..at_idx].chars().next_back().unwrap_or(' '); + if Self::is_telegram_username_char(prev) { + continue; + } + } + + let username_start = at_idx + 1; + let mut username_end = username_start; + + for (rel_idx, candidate_ch) in text[username_start..].char_indices() { + if Self::is_telegram_username_char(candidate_ch) { + username_end = username_start + rel_idx + candidate_ch.len_utf8(); + } else { + break; + } + } + + if username_end == username_start { + continue; + } + + let mention_username = &text[username_start..username_end]; + if mention_username.eq_ignore_ascii_case(bot_username) { + spans.push((at_idx, username_end)); + } + } + + spans + } + + pub(crate) fn contains_bot_mention(text: &str, bot_username: &str) -> bool { + !Self::find_bot_mention_spans(text, bot_username).is_empty() + } + + pub(crate) fn normalize_incoming_content(text: &str, bot_username: &str) -> Option { + let spans = Self::find_bot_mention_spans(text, bot_username); + if spans.is_empty() { + let normalized = text.split_whitespace().collect::>().join(" "); + return (!normalized.is_empty()).then_some(normalized); + } + + let mut normalized = String::with_capacity(text.len()); + let mut cursor = 0; + for (start, end) in spans { + normalized.push_str(&text[cursor..start]); + cursor = end; + } + normalized.push_str(&text[cursor..]); + + let normalized = normalized.split_whitespace().collect::>().join(" "); + (!normalized.is_empty()).then_some(normalized) + } + + pub(crate) fn is_group_message(message: &serde_json::Value) -> bool { + message + .get("chat") + .and_then(|c| c.get("type")) + .and_then(|t| t.as_str()) + .map(|t| t == "group" || t == "supergroup") + .unwrap_or(false) + } + + pub(crate) fn is_user_allowed(&self, username: &str) -> bool { + let identity = Self::normalize_identity(username); + self.allowed_users + .read() + .map(|users| { + users + .iter() + .any(|u| u == "*" || u.eq_ignore_ascii_case(&identity)) + }) + .unwrap_or(false) + } + + pub(crate) fn is_any_user_allowed<'a, I>(&self, identities: I) -> bool + where + I: IntoIterator, + { + identities.into_iter().any(|id| self.is_user_allowed(id)) + } + + pub(crate) async fn handle_unauthorized_message(&self, update: &serde_json::Value) { + let Some(message) = update.get("message") else { + return; + }; + + let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else { + return; + }; + + let username_opt = message + .get("from") + .and_then(|from| from.get("username")) + .and_then(serde_json::Value::as_str); + let username = username_opt.unwrap_or("unknown"); + let normalized_username = Self::normalize_identity(username); + + let sender_id = message + .get("from") + .and_then(|from| from.get("id")) + .and_then(serde_json::Value::as_i64); + let sender_id_str = sender_id.map(|id| id.to_string()); + let normalized_sender_id = sender_id_str.as_deref().map(Self::normalize_identity); + + let chat_id = message + .get("chat") + .and_then(|chat| chat.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + let Some(chat_id) = chat_id else { + tracing::warn!("Telegram: missing chat_id in message, skipping"); + return; + }; + + let mut identities = vec![normalized_username.as_str()]; + if let Some(ref id) = normalized_sender_id { + identities.push(id.as_str()); + } + + if self.is_any_user_allowed(identities.iter().copied()) { + return; + } + + if let Some(code) = Self::extract_bind_code(text) { + if let Some(pairing) = self.pairing.as_ref() { + match pairing.try_pair(code).await { + Ok(Some(_token)) => { + let bind_identity = normalized_sender_id.clone().or_else(|| { + if normalized_username.is_empty() || normalized_username == "unknown" { + None + } else { + Some(normalized_username.clone()) + } + }); + + if let Some(identity) = bind_identity { + self.add_allowed_identity_runtime(&identity); + match self.persist_allowed_identity(&identity).await { + Ok(()) => { + let _ = self + .send(&SendMessage::new( + "✅ Telegram account bound successfully. You can talk to OpenHuman now.", + &chat_id, + )) + .await; + tracing::info!( + "Telegram: paired and allowlisted identity={identity}" + ); + } + Err(e) => { + tracing::error!( + "Telegram: failed to persist allowlist after bind: {e}" + ); + let _ = self + .send(&SendMessage::new( + "âš ī¸ Bound for this runtime, but failed to persist config. Access may be lost after restart; check config file permissions.", + &chat_id, + )) + .await; + } + } + } else { + let _ = self + .send(&SendMessage::new( + "❌ Could not identify your Telegram account. Ensure your account has a username or stable user ID, then retry.", + &chat_id, + )) + .await; + } + } + Ok(None) => { + let _ = self + .send(&SendMessage::new( + "❌ Invalid binding code. Ask operator for the latest code and retry.", + &chat_id, + )) + .await; + } + Err(lockout_secs) => { + let _ = self + .send(&SendMessage::new( + format!("âŗ Too many invalid attempts. Retry in {lockout_secs}s."), + &chat_id, + )) + .await; + } + } + } else { + let _ = self + .send(&SendMessage::new( + "â„šī¸ Telegram pairing is not active. Ask operator to update allowlist in config.toml.", + &chat_id, + )) + .await; + } + return; + } + + tracing::warn!( + "Telegram: ignoring message from unauthorized user: username={username}, sender_id={}. \ +Allowlist Telegram username (without '@') or numeric user ID.", + sender_id_str.as_deref().unwrap_or("unknown") + ); + + let _ = self + .send(&SendMessage::new( + "🔐 This bot requires operator approval.\n\nAsk the operator to approve the pairing in the web UI, then send your message again.".to_string(), + &chat_id, + )) + .await; + + if self.pairing_code_active() { + let _ = self + .send(&SendMessage::new( + "â„šī¸ If operator provides a one-time pairing code, you can also run `/bind `.", + &chat_id, + )) + .await; + } + } + + pub(crate) fn parse_update_message( + &self, + update: &serde_json::Value, + ) -> Option { + let message = update + .get("message") + .or_else(|| update.get("edited_message"))?; + + let text = message.get("text").and_then(serde_json::Value::as_str)?; + + let username = message + .get("from") + .and_then(|from| from.get("username")) + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + + let sender_id = message + .get("from") + .and_then(|from| from.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + let sender_identity = if username == "unknown" { + sender_id.clone().unwrap_or_else(|| "unknown".to_string()) + } else { + username.clone() + }; + + let mut identities = vec![username.as_str()]; + if let Some(id) = sender_id.as_deref() { + identities.push(id); + } + + if !self.is_any_user_allowed(identities.iter().copied()) { + tracing::debug!( + username = %username, + sender_id = sender_id.as_deref().unwrap_or("none"), + message_len = text.len(), + "[telegram] dropped message: sender not in allowed_users (unauthorized handler may reply)" + ); + return None; + } + + let is_group = Self::is_group_message(message); + if self.mention_only && is_group { + let bot_username = self.bot_username.lock(); + if let Some(ref bot_username) = *bot_username { + if !Self::contains_bot_mention(text, bot_username) { + return None; + } + } else { + return None; + } + } + + let chat_id = message + .get("chat") + .and_then(|chat| chat.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string())?; + + let message_id = message + .get("message_id") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + + // Extract thread/topic ID for forum support + let thread_id = message + .get("message_thread_id") + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + // reply_target: chat_id or chat_id:thread_id format + let reply_target = if let Some(tid) = thread_id { + format!("{}:{}", chat_id, tid) + } else { + chat_id.clone() + }; + + let replied_parent_message_id = message + .get("reply_to_message") + .and_then(|reply| reply.get("message_id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + // Telegram "reply" targeting should point to the inbound message itself so the + // assistant response is visibly attached in chat. We still retain the inbound + // parent reference in logs for reply-context diagnostics. + let outbound_reply_to_message_id = Some(message_id.to_string()); + tracing::debug!( + chat_id, + message_id, + reply_to_parent = replied_parent_message_id.as_deref().unwrap_or("none"), + "Telegram inbound message parsed for reply mapping" + ); + + let content = if self.mention_only && is_group { + let bot_username = self.bot_username.lock(); + let bot_username = bot_username.as_ref()?; + Self::normalize_incoming_content(text, bot_username)? + } else { + text.to_string() + }; + + Some(ChannelMessage { + id: format!("telegram_{chat_id}_{message_id}"), + sender: sender_identity, + reply_target, + content, + channel: "telegram".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + thread_ts: outbound_reply_to_message_id, + }) + } + + pub(crate) fn parse_update_reaction( + &self, + update: &serde_json::Value, + ) -> Option { + let reaction = update.get("message_reaction")?; + + let chat_id = reaction + .get("chat") + .and_then(|chat| chat.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string())?; + let message_id = reaction + .get("message_id") + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string())?; + let actor = reaction + .get("user") + .and_then(|user| user.get("username")) + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + .or_else(|| { + reaction + .get("user") + .and_then(|user| user.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + let user_id = reaction + .get("user") + .and_then(|user| user.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + let actor_allowed = self.is_user_allowed(&actor); + let user_id_allowed = user_id + .as_deref() + .is_some_and(|id| self.is_user_allowed(id)); + + if !(actor_allowed || user_id_allowed) { + tracing::debug!( + actor, + message_id, + "Telegram reaction ignored: actor is not allowlisted" + ); + return None; + } + + let emoji = reaction + .get("new_reaction") + .and_then(serde_json::Value::as_array) + .and_then(|arr| { + arr.iter().find_map(|entry| { + entry + .get("emoji") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + }) + })?; + + Some(TelegramReactionEvent { + sender: actor, + reply_target: chat_id, + target_message_id: message_id, + emoji, + }) + } +} diff --git a/src/openhuman/channels/providers/telegram/channel_send.rs b/src/openhuman/channels/providers/telegram/channel_send.rs new file mode 100644 index 000000000..3ca820336 --- /dev/null +++ b/src/openhuman/channels/providers/telegram/channel_send.rs @@ -0,0 +1,687 @@ +//! Telegram channel — outbound message sending: text chunking, media uploads, reaction sending, +//! and attachment dispatch. + +use super::attachments::{ + is_http_url, parse_attachment_markers, parse_path_only_attachment, TelegramAttachment, + TelegramAttachmentKind, +}; +use super::channel_types::TelegramChannel; +use super::text::split_message_for_telegram; +use crate::core::event_bus::{publish_global, DomainEvent}; +use reqwest::multipart::{Form, Part}; +use std::path::Path; +use std::time::Duration; + +impl TelegramChannel { + pub(crate) fn parse_reaction_marker(content: &str) -> (String, Option) { + // Marker format at the start of the message: [REACTION:😀] or [REACTION:😀|12345] + // The marker may be followed by a text reply: [REACTION:👍] Great point! + // Returns (remaining_text, Some(marker_inner)) or (original, None). + let trimmed = content.trim(); + let Some(rest) = trimmed.strip_prefix("[REACTION:") else { + return (content.to_string(), None); + }; + let Some(close_pos) = rest.find(']') else { + return (content.to_string(), None); + }; + let inner = rest[..close_pos].trim(); + if inner.is_empty() { + return (String::new(), None); + } + let remaining = rest[close_pos + 1..].trim().to_string(); + (remaining, Some(inner.to_string())) + } + + pub(crate) async fn send_message_reaction( + &self, + chat_id: &str, + message_id: i64, + emoji: &str, + ) -> anyhow::Result { + let emoji = emoji.trim(); + if emoji.is_empty() { + return Ok(false); + } + + let body = serde_json::json!({ + "chat_id": chat_id, + "message_id": message_id, + "reaction": [ + { + "type": "emoji", + "emoji": emoji + } + ], + "is_big": false + }); + let resp = self + .http_client() + .post(self.api_url("setMessageReaction")) + .json(&body) + .send() + .await?; + if resp.status().is_success() { + publish_global(DomainEvent::ChannelReactionSent { + channel: "telegram".to_string(), + target_message_id: format!("telegram_{chat_id}_{message_id}"), + emoji: emoji.to_string(), + success: true, + }); + tracing::info!(chat_id, message_id, emoji, "Telegram reaction sent"); + return Ok(true); + } + + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + tracing::warn!( + chat_id, + message_id, + emoji, + status = ?status, + error = err, + "Telegram reaction not applied; continuing without failure" + ); + publish_global(DomainEvent::ChannelReactionSent { + channel: "telegram".to_string(), + target_message_id: format!("telegram_{chat_id}_{message_id}"), + emoji: emoji.to_string(), + success: false, + }); + Ok(false) + } + + pub(crate) async fn send_text_chunks( + &self, + message: &str, + chat_id: &str, + thread_id: Option<&str>, + reply_to_message_id: Option, + ) -> anyhow::Result<()> { + let chunks = split_message_for_telegram(message); + + for (index, chunk) in chunks.iter().enumerate() { + let text = if chunks.len() > 1 { + if index == 0 { + format!("{chunk}\n\n(continues...)") + } else if index == chunks.len() - 1 { + format!("(continued)\n\n{chunk}") + } else { + format!("(continued)\n\n{chunk}\n\n(continues...)") + } + } else { + chunk.to_string() + }; + + let mut markdown_body = serde_json::json!({ + "chat_id": chat_id, + "text": text, + "parse_mode": "Markdown" + }); + + // Add message_thread_id for forum topic support + if let Some(tid) = thread_id { + markdown_body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + if index == 0 { + if let Some(parent_id) = reply_to_message_id { + markdown_body["reply_to_message_id"] = serde_json::Value::from(parent_id); + } + } + + let markdown_resp = self + .http_client() + .post(self.api_url("sendMessage")) + .json(&markdown_body) + .send() + .await?; + + if markdown_resp.status().is_success() { + if index < chunks.len() - 1 { + tokio::time::sleep(Duration::from_millis(100)).await; + } + continue; + } + + let markdown_status = markdown_resp.status(); + let markdown_err = markdown_resp.text().await.unwrap_or_default(); + tracing::warn!( + status = ?markdown_status, + "Telegram sendMessage with Markdown failed; retrying without parse_mode" + ); + + let mut plain_body = serde_json::json!({ + "chat_id": chat_id, + "text": text, + }); + + // Add message_thread_id for forum topic support + if let Some(tid) = thread_id { + plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + if index == 0 { + if let Some(parent_id) = reply_to_message_id { + plain_body["reply_to_message_id"] = serde_json::Value::from(parent_id); + } + } + let plain_resp = self + .http_client() + .post(self.api_url("sendMessage")) + .json(&plain_body) + .send() + .await?; + + if !plain_resp.status().is_success() { + let plain_status = plain_resp.status(); + let plain_err = plain_resp.text().await.unwrap_or_default(); + anyhow::bail!( + "Telegram sendMessage failed (markdown {}: {}; plain {}: {})", + markdown_status, + markdown_err, + plain_status, + plain_err + ); + } + + if index < chunks.len() - 1 { + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + Ok(()) + } + + async fn send_media_by_url( + &self, + method: &str, + media_field: &str, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let mut body = serde_json::json!({ + "chat_id": chat_id, + }); + body[media_field] = serde_json::Value::String(url.to_string()); + + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + + if let Some(cap) = caption { + body["caption"] = serde_json::Value::String(cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url(method)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram {method} by URL failed: {err}"); + } + + tracing::info!("Telegram {method} sent to {chat_id}: {url}"); + Ok(()) + } + + pub(crate) async fn send_attachment( + &self, + chat_id: &str, + thread_id: Option<&str>, + attachment: &TelegramAttachment, + ) -> anyhow::Result<()> { + let target = attachment.target.trim(); + + if is_http_url(target) { + return match attachment.kind { + TelegramAttachmentKind::Image => { + self.send_photo_by_url(chat_id, thread_id, target, None) + .await + } + TelegramAttachmentKind::Document => { + self.send_document_by_url(chat_id, thread_id, target, None) + .await + } + TelegramAttachmentKind::Video => { + self.send_video_by_url(chat_id, thread_id, target, None) + .await + } + TelegramAttachmentKind::Audio => { + self.send_audio_by_url(chat_id, thread_id, target, None) + .await + } + TelegramAttachmentKind::Voice => { + self.send_voice_by_url(chat_id, thread_id, target, None) + .await + } + }; + } + + let path = Path::new(target); + if !path.exists() { + anyhow::bail!("Telegram attachment path not found: {target}"); + } + + match attachment.kind { + TelegramAttachmentKind::Image => self.send_photo(chat_id, thread_id, path, None).await, + TelegramAttachmentKind::Document => { + self.send_document(chat_id, thread_id, path, None).await + } + TelegramAttachmentKind::Video => self.send_video(chat_id, thread_id, path, None).await, + TelegramAttachmentKind::Audio => self.send_audio(chat_id, thread_id, path, None).await, + TelegramAttachmentKind::Voice => self.send_voice(chat_id, thread_id, path, None).await, + } + } + + /// Send a document/file to a Telegram chat + pub async fn send_document( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_path: &Path, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file"); + + let file_bytes = tokio::fs::read(file_path).await?; + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("document", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendDocument")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendDocument failed: {err}"); + } + + tracing::info!("Telegram document sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a document from bytes (in-memory) to a Telegram chat + pub async fn send_document_bytes( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_bytes: Vec, + file_name: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("document", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendDocument")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendDocument failed: {err}"); + } + + tracing::info!("Telegram document sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a photo to a Telegram chat + pub async fn send_photo( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_path: &Path, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("photo.jpg"); + + let file_bytes = tokio::fs::read(file_path).await?; + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("photo", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendPhoto")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendPhoto failed: {err}"); + } + + tracing::info!("Telegram photo sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a photo from bytes (in-memory) to a Telegram chat + pub async fn send_photo_bytes( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_bytes: Vec, + file_name: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("photo", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendPhoto")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendPhoto failed: {err}"); + } + + tracing::info!("Telegram photo sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a video to a Telegram chat + pub async fn send_video( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_path: &Path, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("video.mp4"); + + let file_bytes = tokio::fs::read(file_path).await?; + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("video", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendVideo")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendVideo failed: {err}"); + } + + tracing::info!("Telegram video sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send an audio file to a Telegram chat + pub async fn send_audio( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_path: &Path, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("audio.mp3"); + + let file_bytes = tokio::fs::read(file_path).await?; + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("audio", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendAudio")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendAudio failed: {err}"); + } + + tracing::info!("Telegram audio sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a voice message to a Telegram chat + pub async fn send_voice( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_path: &Path, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("voice.ogg"); + + let file_bytes = tokio::fs::read(file_path).await?; + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("voice", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendVoice")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendVoice failed: {err}"); + } + + tracing::info!("Telegram voice sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a file by URL (Telegram will download it) + pub async fn send_document_by_url( + &self, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let mut body = serde_json::json!({ + "chat_id": chat_id, + "document": url + }); + + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + + if let Some(cap) = caption { + body["caption"] = serde_json::Value::String(cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendDocument")) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendDocument by URL failed: {err}"); + } + + tracing::info!("Telegram document (URL) sent to {chat_id}: {url}"); + Ok(()) + } + + /// Send a photo by URL (Telegram will download it) + pub async fn send_photo_by_url( + &self, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let mut body = serde_json::json!({ + "chat_id": chat_id, + "photo": url + }); + + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + + if let Some(cap) = caption { + body["caption"] = serde_json::Value::String(cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendPhoto")) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendPhoto by URL failed: {err}"); + } + + tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}"); + Ok(()) + } + + /// Send a video by URL (Telegram will download it) + pub async fn send_video_by_url( + &self, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + self.send_media_by_url("sendVideo", "video", chat_id, thread_id, url, caption) + .await + } + + /// Send an audio file by URL (Telegram will download it) + pub async fn send_audio_by_url( + &self, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + self.send_media_by_url("sendAudio", "audio", chat_id, thread_id, url, caption) + .await + } + + /// Send a voice message by URL (Telegram will download it) + pub async fn send_voice_by_url( + &self, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + self.send_media_by_url("sendVoice", "voice", chat_id, thread_id, url, caption) + .await + } +} diff --git a/src/openhuman/channels/providers/telegram/channel_types.rs b/src/openhuman/channels/providers/telegram/channel_types.rs new file mode 100644 index 000000000..4fd140806 --- /dev/null +++ b/src/openhuman/channels/providers/telegram/channel_types.rs @@ -0,0 +1,45 @@ +//! Telegram channel — private types and the main struct definition. + +use crate::openhuman::config::StreamMode; +use crate::openhuman::security::pairing::PairingGuard; +use parking_lot::Mutex; +use std::collections::{HashSet, VecDeque}; +use std::sync::{Arc, RwLock}; + +pub(crate) const TELEGRAM_RECENT_UPDATE_CACHE_SIZE: usize = 4096; + +pub(crate) struct TelegramTypingTask { + pub(crate) recipient: String, + pub(crate) handle: tokio::task::JoinHandle<()>, +} + +#[derive(Default)] +pub(crate) struct TelegramUpdateWindow { + pub(crate) max_seen_update_id: i64, + pub(crate) recent_order: VecDeque, + pub(crate) recent_lookup: HashSet, +} + +#[derive(Debug, Clone)] +pub(crate) struct TelegramReactionEvent { + pub(crate) sender: String, + pub(crate) reply_target: String, + pub(crate) target_message_id: String, + pub(crate) emoji: String, +} + +/// Telegram channel — long-polls the Bot API for updates +pub struct TelegramChannel { + pub(crate) bot_token: String, + pub(crate) allowed_users: Arc>>, + pub(crate) pairing: Option, + pub(crate) client: reqwest::Client, + pub(crate) typing_handle: Mutex>, + pub(crate) stream_mode: StreamMode, + pub(crate) draft_update_interval_ms: u64, + pub(crate) silent_streaming: bool, + pub(crate) last_draft_edit: Mutex>, + pub(crate) mention_only: bool, + pub(crate) bot_username: Mutex>, + pub(crate) recent_updates: Mutex, +} diff --git a/src/openhuman/channels/providers/telegram/mod.rs b/src/openhuman/channels/providers/telegram/mod.rs index 03260482a..9c14c9bd3 100644 --- a/src/openhuman/channels/providers/telegram/mod.rs +++ b/src/openhuman/channels/providers/telegram/mod.rs @@ -2,6 +2,11 @@ mod attachments; mod channel; +mod channel_core; +mod channel_ops; +mod channel_recv; +mod channel_send; +mod channel_types; mod text; -pub use channel::TelegramChannel; +pub use channel_types::TelegramChannel; diff --git a/src/openhuman/composio/providers/catalogs.rs b/src/openhuman/composio/providers/catalogs.rs index efcea1739..329584bc3 100644 --- a/src/openhuman/composio/providers/catalogs.rs +++ b/src/openhuman/composio/providers/catalogs.rs @@ -8,2041 +8,25 @@ //! Slugs sourced from `https://docs.composio.dev/toolkits/.md` — //! best-effort. Slugs that don't exist on the backend simply never //! appear in `composio_list_tools`, so extras are harmless. +//! +//! Data is split into category submodules: +//! - [`catalogs_messaging`] — Slack, Discord, Telegram, WhatsApp, MS Teams +//! - [`catalogs_google`] — GoogleCalendar, GoogleDrive, GoogleDocs, GoogleSheets +//! - [`catalogs_productivity`] — Outlook, Linear, Jira, Trello, Asana, Dropbox +//! - [`catalogs_social_media`] — Twitter, Spotify, YouTube +//! - [`catalogs_business`] — Shopify, Stripe, HubSpot, Salesforce, Airtable, Figma -use super::tool_scope::{CuratedTool, ToolScope}; - -// ── slack ─────────────────────────────────────────────────────────── -pub const SLACK_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "SLACK_FIND_CHANNELS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SLACK_FIND_USERS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SLACK_FETCH_CONVERSATION_HISTORY", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SLACK_LIST_ALL_CHANNELS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SLACK_LIST_ALL_USERS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SLACK_LIST_CONVERSATIONS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SLACK_FETCH_TEAM_INFO", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SLACK_GET_USER_PRESENCE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SLACK_ASSISTANT_SEARCH_CONTEXT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SLACK_SEND_MESSAGE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SLACK_POST_MESSAGE_TO_CHANNEL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SLACK_SEND_MESSAGE_TO_CHANNEL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SLACK_CREATE_CHANNEL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SLACK_ADD_REACTION_TO_AN_ITEM", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SLACK_UPLOAD_FILE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SLACK_CREATE_A_REMINDER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SLACK_CREATE_USER_GROUP", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SLACK_DELETE_CHANNEL", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SLACK_ARCHIVE_CONVERSATION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SLACK_DELETE_FILE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SLACK_DELETE_REMINDER", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SLACK_LEAVE_CONVERSATION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SLACK_INVITE_USER_TO_WORKSPACE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SLACK_CONVERT_CHANNEL_TO_PRIVATE", - scope: ToolScope::Admin, - }, -]; - -// ── discord ───────────────────────────────────────────────────────── -pub const DISCORD_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "DISCORD_GET_MY_USER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DISCORD_GET_USER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DISCORD_LIST_MY_GUILDS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DISCORD_GET_MY_GUILD_MEMBER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DISCORD_INVITE_RESOLVE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DISCORD_GET_GUILD_WIDGET", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DISCORD_LIST_MY_CONNECTIONS", - scope: ToolScope::Read, - }, -]; - -// ── googlecalendar ────────────────────────────────────────────────── -pub const GOOGLECALENDAR_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "GOOGLECALENDAR_EVENTS_LIST", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLECALENDAR_FIND_EVENT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLECALENDAR_LIST_CALENDARS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLECALENDAR_EVENTS_GET", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLECALENDAR_FIND_FREE_SLOTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLECALENDAR_GET_CALENDAR", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLECALENDAR_EVENTS_LIST_ALL_CALENDARS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLECALENDAR_CREATE_EVENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLECALENDAR_UPDATE_EVENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLECALENDAR_PATCH_EVENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLECALENDAR_QUICK_ADD", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLECALENDAR_EVENTS_MOVE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLECALENDAR_REMOVE_ATTENDEE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLECALENDAR_EVENTS_IMPORT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLECALENDAR_DELETE_EVENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLECALENDAR_CLEAR_CALENDAR", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLECALENDAR_CALENDARS_DELETE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLECALENDAR_DUPLICATE_CALENDAR", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLECALENDAR_PATCH_CALENDAR", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLECALENDAR_ACL_INSERT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLECALENDAR_ACL_DELETE", - scope: ToolScope::Admin, - }, -]; - -// ── googledrive ───────────────────────────────────────────────────── -pub const GOOGLEDRIVE_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "GOOGLEDRIVE_FIND_FILE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLEDRIVE_LIST_FILES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLEDRIVE_GET_FILE_METADATA", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLEDRIVE_DOWNLOAD_FILE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLEDRIVE_LIST_PERMISSIONS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLEDRIVE_FIND_FOLDER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLEDRIVE_GET_ABOUT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLEDRIVE_CREATE_FILE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDRIVE_CREATE_FOLDER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDRIVE_UPLOAD_FILE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDRIVE_CREATE_FILE_FROM_TEXT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDRIVE_COPY_FILE_ADVANCED", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDRIVE_MOVE_FILE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDRIVE_EDIT_FILE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDRIVE_RENAME_FILE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDRIVE_CREATE_PERMISSION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLEDRIVE_DELETE_PERMISSION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLEDRIVE_UPDATE_PERMISSION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLEDRIVE_DELETE_FILE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLEDRIVE_GOOGLE_DRIVE_DELETE_FOLDER_OR_FILE_ACTION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLEDRIVE_EMPTY_TRASH", - scope: ToolScope::Admin, - }, -]; - -// ── googledocs ────────────────────────────────────────────────────── -pub const GOOGLEDOCS_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "GOOGLEDOCS_GET_DOCUMENT_BY_ID", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLEDOCS_SEARCH_DOCUMENTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLEDOCS_CREATE_DOCUMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_CREATE_DOCUMENT_MARKDOWN", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_INSERT_TEXT_ACTION", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_INSERT_TABLE_ACTION", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_INSERT_INLINE_IMAGE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_UPDATE_EXISTING_DOCUMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_UPDATE_DOCUMENT_SECTION_MARKDOWN", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_REPLACE_ALL_TEXT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_COPY_DOCUMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_CREATE_HEADER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_CREATE_FOOTER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLEDOCS_DELETE_CONTENT_RANGE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLEDOCS_DELETE_HEADER", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLEDOCS_DELETE_FOOTER", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLEDOCS_DELETE_NAMED_RANGE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLEDOCS_DELETE_TABLE_ROW", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLEDOCS_DELETE_TABLE_COLUMN", - scope: ToolScope::Admin, - }, -]; - -// ── googlesheets ──────────────────────────────────────────────────── -pub const GOOGLESHEETS_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "GOOGLESHEETS_BATCH_GET", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLESHEETS_VALUES_GET", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLESHEETS_LOOKUP_SPREADSHEET_ROW", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLESHEETS_GET_SPREADSHEET_INFO", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLESHEETS_GET_SHEET_NAMES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLESHEETS_SEARCH_SPREADSHEETS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "GOOGLESHEETS_VALUES_UPDATE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_UPDATE_VALUES_BATCH", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_SPREADSHEETS_VALUES_APPEND", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_UPSERT_ROWS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_CREATE_GOOGLE_SHEET1", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_ADD_SHEET", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_CREATE_SPREADSHEET_ROW", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_CREATE_SPREADSHEET_COLUMN", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_FIND_REPLACE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_FORMAT_CELL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_SET_DATA_VALIDATION_RULE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "GOOGLESHEETS_SPREADSHEETS_VALUES_BATCH_CLEAR", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLESHEETS_DELETE_SHEET", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLESHEETS_DELETE_DIMENSION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLESHEETS_UPDATE_SHEET_PROPERTIES", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "GOOGLESHEETS_UPDATE_SPREADSHEET_PROPERTIES", - scope: ToolScope::Admin, - }, -]; - -// ── outlook ───────────────────────────────────────────────────────── -pub const OUTLOOK_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "OUTLOOK_GET_MESSAGE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "OUTLOOK_LIST_MESSAGES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "OUTLOOK_SEARCH_MESSAGES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "OUTLOOK_LIST_CALENDARS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "OUTLOOK_LIST_CALENDAR_EVENTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "OUTLOOK_GET_CALENDAR_EVENT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "OUTLOOK_LIST_CONTACTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "OUTLOOK_LIST_MAIL_FOLDERS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "OUTLOOK_SEND_EMAIL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "OUTLOOK_CREATE_DRAFT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "OUTLOOK_SEND_DRAFT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "OUTLOOK_CREATE_DRAFT_REPLY", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "OUTLOOK_CREATE_ME_FORWARD_DRAFT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "OUTLOOK_CALENDAR_CREATE_EVENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "OUTLOOK_CREATE_CONTACT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "OUTLOOK_CREATE_MAIL_FOLDER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "OUTLOOK_DELETE_MESSAGE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "OUTLOOK_BATCH_MOVE_MESSAGES", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "OUTLOOK_BATCH_UPDATE_MESSAGES", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "OUTLOOK_ACCEPT_EVENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "OUTLOOK_CANCEL_EVENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "OUTLOOK_CREATE_ME_CALENDAR_PERMISSION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "OUTLOOK_CREATE_EMAIL_RULE", - scope: ToolScope::Admin, - }, -]; - -// ── microsoft_teams ───────────────────────────────────────────────── -pub const MICROSOFT_TEAMS_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "MICROSOFT_TEAMS_GET_CHAT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_GET_CHANNEL", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_GET_TEAM_FROM_GROUP", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_CHATS_GET_ALL_CHATS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_GET_PRESENCE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_GET_ONLINE_MEETING", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_GET_SCHEDULE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_CREATE_CHANNEL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_CREATE_TEAM", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_CREATE_MEETING", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_ADD_TEAM_MEMBER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_ADD_CHAT_MEMBER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_CREATE_SHIFT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_CREATE_TIME_OFF_REQUEST", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_DELETE_TEAM", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_DELETE_CHANNEL", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_ARCHIVE_TEAM", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_ARCHIVE_CHANNEL", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_DELETE_TAB", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "MICROSOFT_TEAMS_DELETE_TIME_OFF", - scope: ToolScope::Admin, - }, -]; - -// ── linear ────────────────────────────────────────────────────────── -pub const LINEAR_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "LINEAR_LIST_LINEAR_ISSUES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "LINEAR_GET_LINEAR_ISSUE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "LINEAR_LIST_LINEAR_TEAMS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "LINEAR_LIST_LINEAR_PROJECTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "LINEAR_LIST_LINEAR_STATES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "LINEAR_SEARCH_ISSUES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "LINEAR_GET_CYCLES_BY_TEAM_ID", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "LINEAR_LIST_LINEAR_USERS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "LINEAR_LIST_LINEAR_LABELS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "LINEAR_GET_LINEAR_PROJECT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "LINEAR_CREATE_LINEAR_ISSUE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "LINEAR_UPDATE_ISSUE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "LINEAR_CREATE_LINEAR_COMMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "LINEAR_CREATE_ATTACHMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "LINEAR_CREATE_LINEAR_PROJECT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "LINEAR_CREATE_LINEAR_LABEL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "LINEAR_UPDATE_LINEAR_COMMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "LINEAR_CREATE_ISSUE_RELATION", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "LINEAR_UPDATE_LINEAR_PROJECT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "LINEAR_DELETE_LINEAR_ISSUE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "LINEAR_REMOVE_ISSUE_LABEL", - scope: ToolScope::Admin, - }, -]; - -// ── jira ──────────────────────────────────────────────────────────── -pub const JIRA_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "JIRA_GET_ISSUE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "JIRA_GET_ALL_PROJECTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "JIRA_FETCH_BULK_ISSUES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "JIRA_GET_ISSUE_TYPES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "JIRA_GET_PROJECT_ROLES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "JIRA_FIND_USERS2", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "JIRA_GET_FIELDS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "JIRA_GET_ISSUE_EDIT_METADATA", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "JIRA_GET_PROJECT_VERSIONS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "JIRA_CREATE_ISSUE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "JIRA_BULK_CREATE_ISSUE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "JIRA_EDIT_ISSUE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "JIRA_ADD_COMMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "JIRA_ASSIGN_ISSUE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "JIRA_ADD_ATTACHMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "JIRA_CREATE_ISSUE_LINK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "JIRA_ADD_WORKLOG", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "JIRA_TRANSITION_ISSUE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "JIRA_DELETE_ISSUE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "JIRA_DELETE_COMMENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "JIRA_DELETE_VERSION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "JIRA_DELETE_WORKLOG", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "JIRA_CREATE_PROJECT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "JIRA_ADD_USERS_TO_PROJECT_ROLE", - scope: ToolScope::Admin, - }, -]; - -// ── trello ────────────────────────────────────────────────────────── -pub const TRELLO_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "TRELLO_GET_BOARDS_BY_ID_BOARD", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TRELLO_GET_ACTIONS_BY_ID_ACTION", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TRELLO_GET_BATCH", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TRELLO_GET_BOARDS_ACTIONS_BY_ID_BOARD", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TRELLO_GET_MEMBERS_BOARDS_BY_ID_MEMBER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TRELLO_ADD_CARDS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TRELLO_ADD_BOARDS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TRELLO_ADD_LISTS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TRELLO_ADD_CARDS_ACTIONS_COMMENTS_BY_ID_CARD", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TRELLO_ADD_MEMBER_TO_CARD", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TRELLO_CREATE_CARD_LABEL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TRELLO_ADD_CARDS_ATTACHMENTS_BY_ID_CARD", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TRELLO_ADD_CARDS_CHECKLISTS_BY_ID_CARD", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TRELLO_CREATE_WEBHOOK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TRELLO_DELETE_CARDS_BY_ID_CARD", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TRELLO_DELETE_BOARD", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TRELLO_DELETE_CHECKLISTS_BY_ID_CHECKLIST", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TRELLO_ARCHIVE_ALL_LIST_CARDS", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TRELLO_DELETE_CARD_COMMENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TRELLO_DELETE_LABELS_BY_ID_LABEL", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TRELLO_DELETE_ORGANIZATIONS_BY_ID_ORG", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TRELLO_DELETE_WEBHOOKS_BY_ID_WEBHOOK", - scope: ToolScope::Admin, - }, -]; - -// ── asana ─────────────────────────────────────────────────────────── -pub const ASANA_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "ASANA_GET_A_TASK", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "ASANA_GET_A_PROJECT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "ASANA_GET_MULTIPLE_TASKS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "ASANA_GET_MULTIPLE_PROJECTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "ASANA_GET_CURRENT_USER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "ASANA_GET_MULTIPLE_WORKSPACES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "ASANA_GET_PORTFOLIO", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "ASANA_GET_GOALS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "ASANA_GET_CUSTOM_FIELDS_FOR_WORKSPACE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "ASANA_CREATE_A_TASK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "ASANA_CREATE_A_PROJECT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "ASANA_CREATE_SUBTASK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "ASANA_CREATE_TASK_COMMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "ASANA_UPDATE_A_TASK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "ASANA_ADD_FOLLOWERS_TO_TASK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "ASANA_ADD_TAG_TO_TASK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "ASANA_ADD_PROJECT_FOR_TASK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "ASANA_ADD_TASK_DEPENDENCIES", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "ASANA_CREATE_ATTACHMENT_FOR_TASK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "ASANA_DELETE_TASK", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "ASANA_DELETE_PROJECT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "ASANA_DELETE_SECTION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "ASANA_DELETE_TAG", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "ASANA_DELETE_CUSTOM_FIELD", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "ASANA_DELETE_MEMBERSHIP", - scope: ToolScope::Admin, - }, -]; - -// ── dropbox ───────────────────────────────────────────────────────── -pub const DROPBOX_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "DROPBOX_GET_METADATA", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DROPBOX_FILES_SEARCH", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DROPBOX_LIST_FILE_MEMBERS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DROPBOX_GET_SHARED_LINK_METADATA", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DROPBOX_GET_ABOUT_ME", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DROPBOX_GET_SPACE_USAGE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "DROPBOX_ALPHA_UPLOAD_FILE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "DROPBOX_CREATE_FOLDER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "DROPBOX_COPY_FILE_OR_FOLDER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "DROPBOX_CREATE_SHARED_LINK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "DROPBOX_ADD_FILE_MEMBER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "DROPBOX_DELETE_FILE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "DROPBOX_DELETE_BATCH", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "DROPBOX_ADD_TEAM_MEMBERS", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "DROPBOX_CREATE_TEAM_FOLDER", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "DROPBOX_ARCHIVE_TEAM_FOLDER", - scope: ToolScope::Admin, - }, -]; - -// ── twitter ───────────────────────────────────────────────────────── -pub const TWITTER_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "TWITTER_RECENT_SEARCH", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TWITTER_GET_USER_BY_ID", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TWITTER_POST_LOOKUP_BY_POST_ID", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TWITTER_FOLLOWERS_BY_USER_ID", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TWITTER_FOLLOWING_BY_USER_ID", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TWITTER_BOOKMARKS_BY_USER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TWITTER_GET_LIST_MEMBERS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TWITTER_FULL_ARCHIVE_SEARCH", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TWITTER_CREATION_OF_A_POST", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TWITTER_RETWEET_POST", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TWITTER_ADD_POST_TO_BOOKMARKS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TWITTER_FOLLOW_USER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TWITTER_MUTE_USER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TWITTER_CREATE_DM_CONVERSATION", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TWITTER_CREATE_LIST", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TWITTER_ADD_LIST_MEMBER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TWITTER_POST_DELETE_BY_POST_ID", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TWITTER_DELETE_LIST", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TWITTER_REMOVE_LIST_MEMBER", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TWITTER_DELETE_DM", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TWITTER_REMOVE_POST_FROM_BOOKMARKS", - scope: ToolScope::Admin, - }, -]; - -// ── spotify ───────────────────────────────────────────────────────── -pub const SPOTIFY_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "SPOTIFY_GET_CURRENT_USER_S_PROFILE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SPOTIFY_GET_USER_S_TOP_TRACKS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SPOTIFY_GET_PLAYLIST", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SPOTIFY_GET_PLAYLIST_ITEMS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SPOTIFY_GET_RECENTLY_PLAYED_TRACKS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SPOTIFY_GET_USER_S_SAVED_TRACKS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SPOTIFY_SEARCH_FOR_ITEM", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SPOTIFY_GET_AVAILABLE_DEVICES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SPOTIFY_ADD_ITEMS_TO_PLAYLIST", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SPOTIFY_CREATE_PLAYLIST", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SPOTIFY_SAVE_TRACKS_FOR_CURRENT_USER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SPOTIFY_PAUSE_PLAYBACK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SPOTIFY_ADD_ITEM_TO_PLAYBACK_QUEUE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SPOTIFY_CHANGE_PLAYLIST_DETAILS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SPOTIFY_REMOVE_PLAYLIST_ITEMS", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SPOTIFY_REMOVE_USER_S_SAVED_TRACKS", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SPOTIFY_UNFOLLOW_ARTISTS_OR_USERS", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SPOTIFY_REMOVE_USER_S_SAVED_ALBUMS", - scope: ToolScope::Admin, - }, -]; - -// ── telegram ──────────────────────────────────────────────────────── -pub const TELEGRAM_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "TELEGRAM_GET_UPDATES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TELEGRAM_GET_CHAT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TELEGRAM_GET_CHAT_HISTORY", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TELEGRAM_GET_CHAT_MEMBER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TELEGRAM_GET_CHAT_MEMBERS_COUNT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TELEGRAM_GET_CHAT_ADMINISTRATORS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TELEGRAM_GET_ME", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "TELEGRAM_SEND_MESSAGE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TELEGRAM_SEND_PHOTO", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TELEGRAM_SEND_DOCUMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TELEGRAM_SEND_LOCATION", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TELEGRAM_SEND_POLL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TELEGRAM_FORWARD_MESSAGE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TELEGRAM_EDIT_MESSAGE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TELEGRAM_ANSWER_CALLBACK_QUERY", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "TELEGRAM_DELETE_MESSAGE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TELEGRAM_CREATE_CHAT_INVITE_LINK", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "TELEGRAM_SET_MY_COMMANDS", - scope: ToolScope::Admin, - }, -]; - -// ── whatsapp ──────────────────────────────────────────────────────── -pub const WHATSAPP_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "WHATSAPP_GET_PHONE_NUMBERS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "WHATSAPP_GET_MESSAGE_TEMPLATES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "WHATSAPP_GET_PHONE_NUMBER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "WHATSAPP_GET_BUSINESS_PROFILE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "WHATSAPP_GET_TEMPLATE_STATUS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "WHATSAPP_GET_MEDIA_INFO", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "WHATSAPP_SEND_MESSAGE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "WHATSAPP_SEND_TEMPLATE_MESSAGE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "WHATSAPP_SEND_MEDIA", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "WHATSAPP_SEND_MEDIA_BY_ID", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "WHATSAPP_SEND_INTERACTIVE_BUTTONS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "WHATSAPP_SEND_INTERACTIVE_LIST", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "WHATSAPP_UPLOAD_MEDIA", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "WHATSAPP_CREATE_MESSAGE_TEMPLATE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "WHATSAPP_DELETE_MESSAGE_TEMPLATE", - scope: ToolScope::Admin, - }, -]; - -// ── shopify ───────────────────────────────────────────────────────── -pub const SHOPIFY_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "SHOPIFY_BULK_QUERY_OPERATION", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SHOPIFY_COUNT_PRODUCTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SHOPIFY_COUNT_ORDERS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SHOPIFY_COUNT_FULFILLMENTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SHOPIFY_COUNT_CUSTOMERS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SHOPIFY_CREATE_ORDER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SHOPIFY_CREATE_PRODUCT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SHOPIFY_CREATE_DRAFT_ORDER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SHOPIFY_CREATE_FULFILLMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SHOPIFY_CREATE_CUSTOMER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SHOPIFY_CREATE_PRICE_RULE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SHOPIFY_ADJUST_INVENTORY_LEVEL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SHOPIFY_CREATE_DISCOUNT_CODE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SHOPIFY_UPDATE_PRODUCT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SHOPIFY_CREATE_CUSTOM_COLLECTION", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SHOPIFY_CANCEL_ORDER", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SHOPIFY_CANCEL_FULFILLMENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SHOPIFY_DELETE_PRODUCT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SHOPIFY_BULK_DELETE_CUSTOMER_ADDRESSES", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SHOPIFY_BULK_DELETE_METAFIELDS", - scope: ToolScope::Admin, - }, -]; - -// ── stripe ────────────────────────────────────────────────────────── -pub const STRIPE_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "STRIPE_GET_PAYMENT_INTENT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "STRIPE_LIST_INVOICES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "STRIPE_GET_CUSTOMER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "STRIPE_LIST_CHARGES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "STRIPE_GET_SUBSCRIPTION", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "STRIPE_CREATE_PAYMENT_INTENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "STRIPE_CREATE_INVOICE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "STRIPE_CREATE_CUSTOMER", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "STRIPE_CREATE_CUSTOMER_SUBSCRIPTION", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "STRIPE_CREATE_CHECKOUT_SESSION", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "STRIPE_CONFIRM_PAYMENT_INTENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "STRIPE_CAPTURE_PAYMENT_INTENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "STRIPE_ATTACH_PAYMENT_METHOD", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "STRIPE_CANCEL_SUBSCRIPTION", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "STRIPE_CANCEL_PAYMENT_INTENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "STRIPE_CREATE_CHARGE_REFUND", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "STRIPE_CLOSE_DISPUTE", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "STRIPE_CANCEL_SETUP_INTENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "STRIPE_ARCHIVE_BILLING_ALERT", - scope: ToolScope::Admin, - }, -]; - -// ── hubspot ───────────────────────────────────────────────────────── -pub const HUBSPOT_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "HUBSPOT_GET_CONTACTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "HUBSPOT_SEARCH_CONTACTS_BY_CRITERIA", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "HUBSPOT_LIST_CONTACTS_PAGE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "HUBSPOT_GET_COMPANIES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "HUBSPOT_GET_DEALS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "HUBSPOT_GET_CRM_OBJECT_BY_ID", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "HUBSPOT_BATCH_READ_COMPANIES_BY_PROPERTIES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "HUBSPOT_CREATE_CONTACT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "HUBSPOT_CREATE_COMPANY", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "HUBSPOT_CREATE_DEAL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "HUBSPOT_CREATE_CONTACTS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "HUBSPOT_UPDATE_CONTACT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "HUBSPOT_UPDATE_COMPANY", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "HUBSPOT_CREATE_OBJECT_ASSOCIATION", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "HUBSPOT_CREATE_A_NEW_MARKETING_EMAIL", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "HUBSPOT_CREATE_BATCH_OF_OBJECTS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "HUBSPOT_BATCH_UPDATE_QUOTES", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "HUBSPOT_ARCHIVE_CONTACT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "HUBSPOT_ARCHIVE_COMPANY", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "HUBSPOT_ARCHIVE_DEAL", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "HUBSPOT_ARCHIVE_CONTACTS", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "HUBSPOT_ARCHIVE_COMPANIES", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "HUBSPOT_ARCHIVE_DEALS", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "HUBSPOT_ARCHIVE_CRM_OBJECT_BY_ID", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "HUBSPOT_ARCHIVE_PROPERTY_BY_OBJECT_TYPE_AND_NAME", - scope: ToolScope::Admin, - }, -]; - -// ── salesforce ────────────────────────────────────────────────────── -pub const SALESFORCE_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "SALESFORCE_RUN_SOQL_QUERY", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SALESFORCE_EXECUTE_SOSL_SEARCH", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SALESFORCE_GET_ACCOUNT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SALESFORCE_GET_CAMPAIGN", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SALESFORCE_GET_ALL_FIELDS_FOR_OBJECT", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SALESFORCE_GET_ALL_CUSTOM_OBJECTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "SALESFORCE_CREATE_ACCOUNT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_CREATE_CONTACT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_CREATE_LEAD", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_CREATE_OPPORTUNITY", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_CREATE_CAMPAIGN", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_CREATE_TASK", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_UPDATE_ACCOUNT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_UPDATE_CONTACT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_UPDATE_OPPORTUNITY", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_ADD_OPPORTUNITY_LINE_ITEM", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_ADD_CONTACT_TO_CAMPAIGN", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_ADD_LEAD_TO_CAMPAIGN", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_ASSOCIATE_CONTACT_TO_ACCOUNT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_CLONE_OPPORTUNITY_WITH_PRODUCTS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "SALESFORCE_DELETE_ACCOUNT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SALESFORCE_DELETE_CONTACT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SALESFORCE_DELETE_LEAD", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SALESFORCE_DELETE_OPPORTUNITY", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SALESFORCE_DELETE_CAMPAIGN", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SALESFORCE_DELETE_SOBJECT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SALESFORCE_DELETE_SOBJECT_COLLECTIONS", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "SALESFORCE_CREATE_CUSTOM_FIELD", - scope: ToolScope::Admin, - }, -]; - -// ── airtable ──────────────────────────────────────────────────────── -pub const AIRTABLE_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "AIRTABLE_LIST_RECORDS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "AIRTABLE_GET_RECORD", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "AIRTABLE_GET_BASE_SCHEMA", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "AIRTABLE_LIST_BASES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "AIRTABLE_LIST_COMMENTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "AIRTABLE_CREATE_RECORDS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "AIRTABLE_UPDATE_RECORD", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "AIRTABLE_UPDATE_MULTIPLE_RECORDS", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "AIRTABLE_CREATE_FIELD", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "AIRTABLE_CREATE_TABLE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "AIRTABLE_CREATE_COMMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "AIRTABLE_UPLOAD_ATTACHMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "AIRTABLE_UPDATE_FIELD", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "AIRTABLE_UPDATE_TABLE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "AIRTABLE_DELETE_RECORD", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "AIRTABLE_DELETE_MULTIPLE_RECORDS", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "AIRTABLE_DELETE_COMMENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "AIRTABLE_CREATE_BASE", - scope: ToolScope::Admin, - }, -]; - -// ── figma ─────────────────────────────────────────────────────────── -pub const FIGMA_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "FIGMA_GET_FILE_JSON", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "FIGMA_GET_FILE_NODES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "FIGMA_GET_COMMENTS_IN_A_FILE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "FIGMA_GET_CURRENT_USER", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "FIGMA_DISCOVER_FIGMA_RESOURCES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "FIGMA_GET_FILE_COMPONENTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "FIGMA_GET_LOCAL_VARIABLES", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "FIGMA_EXTRACT_DESIGN_TOKENS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "FIGMA_ADD_A_COMMENT_TO_A_FILE", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "FIGMA_CREATE_DEV_RESOURCES", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "FIGMA_CREATE_MODIFY_DELETE_VARIABLES", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "FIGMA_DELETE_A_COMMENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "FIGMA_DELETE_A_WEBHOOK", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "FIGMA_DELETE_DEV_RESOURCE", - scope: ToolScope::Admin, - }, -]; - -// ── youtube ───────────────────────────────────────────────────────── -pub const YOUTUBE_CURATED: &[CuratedTool] = &[ - CuratedTool { - slug: "YOUTUBE_SEARCH_YOU_TUBE", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "YOUTUBE_LIST_CHANNEL_VIDEOS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "YOUTUBE_GET_CHANNEL_STATISTICS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "YOUTUBE_LIST_COMMENT_THREADS2", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "YOUTUBE_LIST_COMMENTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "YOUTUBE_GET_VIDEO_DETAILS_BATCH", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "YOUTUBE_LIST_USER_PLAYLISTS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "YOUTUBE_LIST_PLAYLIST_ITEMS", - scope: ToolScope::Read, - }, - CuratedTool { - slug: "YOUTUBE_UPLOAD_VIDEO", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "YOUTUBE_UPDATE_VIDEO", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "YOUTUBE_CREATE_PLAYLIST", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "YOUTUBE_ADD_VIDEO_TO_PLAYLIST", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "YOUTUBE_POST_COMMENT", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "YOUTUBE_RATE_VIDEO", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "YOUTUBE_UPDATE_PLAYLIST", - scope: ToolScope::Write, - }, - CuratedTool { - slug: "YOUTUBE_DELETE_VIDEO", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "YOUTUBE_DELETE_PLAYLIST", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "YOUTUBE_DELETE_COMMENT", - scope: ToolScope::Admin, - }, - CuratedTool { - slug: "YOUTUBE_DELETE_PLAYLIST_ITEM", - scope: ToolScope::Admin, - }, -]; +pub use super::catalogs_business::{ + AIRTABLE_CURATED, FIGMA_CURATED, HUBSPOT_CURATED, SALESFORCE_CURATED, SHOPIFY_CURATED, + STRIPE_CURATED, +}; +pub use super::catalogs_google::{ + GOOGLECALENDAR_CURATED, GOOGLEDOCS_CURATED, GOOGLEDRIVE_CURATED, GOOGLESHEETS_CURATED, +}; +pub use super::catalogs_messaging::{ + DISCORD_CURATED, MICROSOFT_TEAMS_CURATED, SLACK_CURATED, TELEGRAM_CURATED, WHATSAPP_CURATED, +}; +pub use super::catalogs_productivity::{ + ASANA_CURATED, DROPBOX_CURATED, JIRA_CURATED, LINEAR_CURATED, OUTLOOK_CURATED, TRELLO_CURATED, +}; +pub use super::catalogs_social_media::{SPOTIFY_CURATED, TWITTER_CURATED, YOUTUBE_CURATED}; diff --git a/src/openhuman/composio/providers/catalogs_business.rs b/src/openhuman/composio/providers/catalogs_business.rs new file mode 100644 index 000000000..0033aec81 --- /dev/null +++ b/src/openhuman/composio/providers/catalogs_business.rs @@ -0,0 +1,524 @@ +//! Curated catalogs — business toolkits: Shopify, Stripe, HubSpot, +//! Salesforce, Airtable, Figma. + +use super::tool_scope::{CuratedTool, ToolScope}; + +// ── shopify ───────────────────────────────────────────────────────── +pub const SHOPIFY_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "SHOPIFY_BULK_QUERY_OPERATION", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SHOPIFY_COUNT_PRODUCTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SHOPIFY_COUNT_ORDERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SHOPIFY_COUNT_FULFILLMENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SHOPIFY_COUNT_CUSTOMERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_ORDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_PRODUCT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_DRAFT_ORDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_FULFILLMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_CUSTOMER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_PRICE_RULE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_ADJUST_INVENTORY_LEVEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_DISCOUNT_CODE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_UPDATE_PRODUCT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CREATE_CUSTOM_COLLECTION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SHOPIFY_CANCEL_ORDER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SHOPIFY_CANCEL_FULFILLMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SHOPIFY_DELETE_PRODUCT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SHOPIFY_BULK_DELETE_CUSTOMER_ADDRESSES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SHOPIFY_BULK_DELETE_METAFIELDS", + scope: ToolScope::Admin, + }, +]; + +// ── stripe ────────────────────────────────────────────────────────── +pub const STRIPE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "STRIPE_GET_PAYMENT_INTENT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "STRIPE_LIST_INVOICES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "STRIPE_GET_CUSTOMER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "STRIPE_LIST_CHARGES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "STRIPE_GET_SUBSCRIPTION", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "STRIPE_CREATE_PAYMENT_INTENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CREATE_INVOICE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CREATE_CUSTOMER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CREATE_CUSTOMER_SUBSCRIPTION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CREATE_CHECKOUT_SESSION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CONFIRM_PAYMENT_INTENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CAPTURE_PAYMENT_INTENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_ATTACH_PAYMENT_METHOD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "STRIPE_CANCEL_SUBSCRIPTION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "STRIPE_CANCEL_PAYMENT_INTENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "STRIPE_CREATE_CHARGE_REFUND", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "STRIPE_CLOSE_DISPUTE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "STRIPE_CANCEL_SETUP_INTENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "STRIPE_ARCHIVE_BILLING_ALERT", + scope: ToolScope::Admin, + }, +]; + +// ── hubspot ───────────────────────────────────────────────────────── +pub const HUBSPOT_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "HUBSPOT_GET_CONTACTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_SEARCH_CONTACTS_BY_CRITERIA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_LIST_CONTACTS_PAGE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_GET_COMPANIES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_GET_DEALS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_GET_CRM_OBJECT_BY_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_BATCH_READ_COMPANIES_BY_PROPERTIES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_CONTACT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_COMPANY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_DEAL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_CONTACTS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_UPDATE_CONTACT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_UPDATE_COMPANY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_OBJECT_ASSOCIATION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_A_NEW_MARKETING_EMAIL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_CREATE_BATCH_OF_OBJECTS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_BATCH_UPDATE_QUOTES", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_CONTACT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_COMPANY", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_DEAL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_CONTACTS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_COMPANIES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_DEALS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_CRM_OBJECT_BY_ID", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "HUBSPOT_ARCHIVE_PROPERTY_BY_OBJECT_TYPE_AND_NAME", + scope: ToolScope::Admin, + }, +]; + +// ── salesforce ────────────────────────────────────────────────────── +pub const SALESFORCE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "SALESFORCE_RUN_SOQL_QUERY", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_EXECUTE_SOSL_SEARCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_GET_ACCOUNT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_GET_CAMPAIGN", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_GET_ALL_FIELDS_FOR_OBJECT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_GET_ALL_CUSTOM_OBJECTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_ACCOUNT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_CONTACT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_LEAD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_OPPORTUNITY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_CAMPAIGN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_UPDATE_ACCOUNT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_UPDATE_CONTACT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_UPDATE_OPPORTUNITY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_ADD_OPPORTUNITY_LINE_ITEM", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_ADD_CONTACT_TO_CAMPAIGN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_ADD_LEAD_TO_CAMPAIGN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_ASSOCIATE_CONTACT_TO_ACCOUNT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_CLONE_OPPORTUNITY_WITH_PRODUCTS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_ACCOUNT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_CONTACT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_LEAD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_OPPORTUNITY", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_CAMPAIGN", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_SOBJECT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_DELETE_SOBJECT_COLLECTIONS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SALESFORCE_CREATE_CUSTOM_FIELD", + scope: ToolScope::Admin, + }, +]; + +// ── airtable ──────────────────────────────────────────────────────── +pub const AIRTABLE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "AIRTABLE_LIST_RECORDS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "AIRTABLE_GET_RECORD", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "AIRTABLE_GET_BASE_SCHEMA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "AIRTABLE_LIST_BASES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "AIRTABLE_LIST_COMMENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "AIRTABLE_CREATE_RECORDS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_UPDATE_RECORD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_UPDATE_MULTIPLE_RECORDS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_CREATE_FIELD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_CREATE_TABLE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_CREATE_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_UPLOAD_ATTACHMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_UPDATE_FIELD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_UPDATE_TABLE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "AIRTABLE_DELETE_RECORD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "AIRTABLE_DELETE_MULTIPLE_RECORDS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "AIRTABLE_DELETE_COMMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "AIRTABLE_CREATE_BASE", + scope: ToolScope::Admin, + }, +]; + +// ── figma ─────────────────────────────────────────────────────────── +pub const FIGMA_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "FIGMA_GET_FILE_JSON", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_GET_FILE_NODES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_GET_COMMENTS_IN_A_FILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_GET_CURRENT_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_DISCOVER_FIGMA_RESOURCES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_GET_FILE_COMPONENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_GET_LOCAL_VARIABLES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_EXTRACT_DESIGN_TOKENS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "FIGMA_ADD_A_COMMENT_TO_A_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "FIGMA_CREATE_DEV_RESOURCES", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "FIGMA_CREATE_MODIFY_DELETE_VARIABLES", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "FIGMA_DELETE_A_COMMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "FIGMA_DELETE_A_WEBHOOK", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "FIGMA_DELETE_DEV_RESOURCE", + scope: ToolScope::Admin, + }, +]; diff --git a/src/openhuman/composio/providers/catalogs_google.rs b/src/openhuman/composio/providers/catalogs_google.rs new file mode 100644 index 000000000..be25c83d7 --- /dev/null +++ b/src/openhuman/composio/providers/catalogs_google.rs @@ -0,0 +1,360 @@ +//! Curated catalogs — Google toolkits: GoogleCalendar, GoogleDrive, +//! GoogleDocs, GoogleSheets. + +use super::tool_scope::{CuratedTool, ToolScope}; + +// ── googlecalendar ────────────────────────────────────────────────── +pub const GOOGLECALENDAR_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "GOOGLECALENDAR_EVENTS_LIST", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_FIND_EVENT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_LIST_CALENDARS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_EVENTS_GET", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_FIND_FREE_SLOTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_GET_CALENDAR", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_EVENTS_LIST_ALL_CALENDARS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLECALENDAR_CREATE_EVENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_UPDATE_EVENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_PATCH_EVENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_QUICK_ADD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_EVENTS_MOVE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_REMOVE_ATTENDEE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_EVENTS_IMPORT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLECALENDAR_DELETE_EVENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_CLEAR_CALENDAR", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_CALENDARS_DELETE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_DUPLICATE_CALENDAR", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_PATCH_CALENDAR", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_ACL_INSERT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLECALENDAR_ACL_DELETE", + scope: ToolScope::Admin, + }, +]; + +// ── googledrive ───────────────────────────────────────────────────── +pub const GOOGLEDRIVE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "GOOGLEDRIVE_FIND_FILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_LIST_FILES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_GET_FILE_METADATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_DOWNLOAD_FILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_LIST_PERMISSIONS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_FIND_FOLDER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_GET_ABOUT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDRIVE_CREATE_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_CREATE_FOLDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_UPLOAD_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_CREATE_FILE_FROM_TEXT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_COPY_FILE_ADVANCED", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_MOVE_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_EDIT_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_RENAME_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDRIVE_CREATE_PERMISSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDRIVE_DELETE_PERMISSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDRIVE_UPDATE_PERMISSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDRIVE_DELETE_FILE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDRIVE_GOOGLE_DRIVE_DELETE_FOLDER_OR_FILE_ACTION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDRIVE_EMPTY_TRASH", + scope: ToolScope::Admin, + }, +]; + +// ── googledocs ────────────────────────────────────────────────────── +pub const GOOGLEDOCS_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "GOOGLEDOCS_GET_DOCUMENT_BY_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDOCS_GET_DOCUMENT_PLAINTEXT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDOCS_SEARCH_DOCUMENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLEDOCS_CREATE_DOCUMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_CREATE_DOCUMENT_MARKDOWN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_INSERT_TEXT_ACTION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_INSERT_TABLE_ACTION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_INSERT_INLINE_IMAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_UPDATE_EXISTING_DOCUMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_UPDATE_DOCUMENT_MARKDOWN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_UPDATE_DOCUMENT_SECTION_MARKDOWN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_REPLACE_ALL_TEXT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_COPY_DOCUMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_CREATE_HEADER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_CREATE_FOOTER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_CONTENT_RANGE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_HEADER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_FOOTER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_NAMED_RANGE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_TABLE_ROW", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLEDOCS_DELETE_TABLE_COLUMN", + scope: ToolScope::Admin, + }, +]; + +// ── googlesheets ──────────────────────────────────────────────────── +pub const GOOGLESHEETS_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "GOOGLESHEETS_BATCH_GET", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_VALUES_GET", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_LOOKUP_SPREADSHEET_ROW", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_GET_SPREADSHEET_INFO", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_GET_SHEET_NAMES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_SEARCH_SPREADSHEETS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "GOOGLESHEETS_VALUES_UPDATE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_UPDATE_VALUES_BATCH", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_SPREADSHEETS_VALUES_APPEND", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_UPSERT_ROWS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_CREATE_GOOGLE_SHEET1", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_ADD_SHEET", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_CREATE_SPREADSHEET_ROW", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_CREATE_SPREADSHEET_COLUMN", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_FIND_REPLACE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_FORMAT_CELL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_SET_DATA_VALIDATION_RULE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "GOOGLESHEETS_SPREADSHEETS_VALUES_BATCH_CLEAR", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLESHEETS_DELETE_SHEET", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLESHEETS_DELETE_DIMENSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLESHEETS_UPDATE_SHEET_PROPERTIES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "GOOGLESHEETS_UPDATE_SPREADSHEET_PROPERTIES", + scope: ToolScope::Admin, + }, +]; diff --git a/src/openhuman/composio/providers/catalogs_messaging.rs b/src/openhuman/composio/providers/catalogs_messaging.rs new file mode 100644 index 000000000..23ec48a7a --- /dev/null +++ b/src/openhuman/composio/providers/catalogs_messaging.rs @@ -0,0 +1,372 @@ +//! Curated catalogs — messaging toolkits: Slack, Discord, Telegram, +//! WhatsApp, Microsoft Teams. + +use super::tool_scope::{CuratedTool, ToolScope}; + +// ── slack ─────────────────────────────────────────────────────────── +pub const SLACK_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "SLACK_FIND_CHANNELS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_FIND_USERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_FETCH_CONVERSATION_HISTORY", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_LIST_ALL_CHANNELS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_LIST_ALL_USERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_LIST_CONVERSATIONS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_FETCH_TEAM_INFO", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_GET_USER_PRESENCE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_ASSISTANT_SEARCH_CONTEXT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SLACK_SEND_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_POST_MESSAGE_TO_CHANNEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_SEND_MESSAGE_TO_CHANNEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_CREATE_CHANNEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_ADD_REACTION_TO_AN_ITEM", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_UPLOAD_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_CREATE_A_REMINDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_CREATE_USER_GROUP", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SLACK_DELETE_CHANNEL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_ARCHIVE_CONVERSATION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_DELETE_FILE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_DELETE_REMINDER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_LEAVE_CONVERSATION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_INVITE_USER_TO_WORKSPACE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SLACK_CONVERT_CHANNEL_TO_PRIVATE", + scope: ToolScope::Admin, + }, +]; + +// ── discord ───────────────────────────────────────────────────────── +pub const DISCORD_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "DISCORD_GET_MY_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_GET_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_LIST_MY_GUILDS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_GET_MY_GUILD_MEMBER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_INVITE_RESOLVE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_GET_GUILD_WIDGET", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DISCORD_LIST_MY_CONNECTIONS", + scope: ToolScope::Read, + }, +]; + +// ── telegram ──────────────────────────────────────────────────────── +pub const TELEGRAM_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "TELEGRAM_GET_UPDATES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_CHAT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_CHAT_HISTORY", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_CHAT_MEMBER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_CHAT_MEMBERS_COUNT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_CHAT_ADMINISTRATORS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_GET_ME", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TELEGRAM_SEND_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_SEND_PHOTO", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_SEND_DOCUMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_SEND_LOCATION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_SEND_POLL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_FORWARD_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_EDIT_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_ANSWER_CALLBACK_QUERY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TELEGRAM_DELETE_MESSAGE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TELEGRAM_CREATE_CHAT_INVITE_LINK", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TELEGRAM_SET_MY_COMMANDS", + scope: ToolScope::Admin, + }, +]; + +// ── whatsapp ──────────────────────────────────────────────────────── +pub const WHATSAPP_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "WHATSAPP_GET_PHONE_NUMBERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_GET_MESSAGE_TEMPLATES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_GET_PHONE_NUMBER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_GET_BUSINESS_PROFILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_GET_TEMPLATE_STATUS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_GET_MEDIA_INFO", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "WHATSAPP_SEND_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_SEND_TEMPLATE_MESSAGE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_SEND_MEDIA", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_SEND_MEDIA_BY_ID", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_SEND_INTERACTIVE_BUTTONS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_SEND_INTERACTIVE_LIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_UPLOAD_MEDIA", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "WHATSAPP_CREATE_MESSAGE_TEMPLATE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "WHATSAPP_DELETE_MESSAGE_TEMPLATE", + scope: ToolScope::Admin, + }, +]; + +// ── microsoft_teams ───────────────────────────────────────────────── +pub const MICROSOFT_TEAMS_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_CHAT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_CHANNEL", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_TEAM_FROM_GROUP", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CHATS_GET_ALL_CHATS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_PRESENCE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_ONLINE_MEETING", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_GET_SCHEDULE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CREATE_CHANNEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CREATE_TEAM", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CREATE_MEETING", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_ADD_TEAM_MEMBER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_ADD_CHAT_MEMBER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CREATE_SHIFT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_CREATE_TIME_OFF_REQUEST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_DELETE_TEAM", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_DELETE_CHANNEL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_ARCHIVE_TEAM", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_ARCHIVE_CHANNEL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_DELETE_TAB", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "MICROSOFT_TEAMS_DELETE_TIME_OFF", + scope: ToolScope::Admin, + }, +]; diff --git a/src/openhuman/composio/providers/catalogs_productivity.rs b/src/openhuman/composio/providers/catalogs_productivity.rs new file mode 100644 index 000000000..4b911fd07 --- /dev/null +++ b/src/openhuman/composio/providers/catalogs_productivity.rs @@ -0,0 +1,552 @@ +//! Curated catalogs — productivity toolkits: Outlook, Linear, Jira, +//! Trello, Asana, Dropbox. + +use super::tool_scope::{CuratedTool, ToolScope}; + +// ── outlook ───────────────────────────────────────────────────────── +pub const OUTLOOK_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "OUTLOOK_GET_MESSAGE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_LIST_MESSAGES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_SEARCH_MESSAGES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_LIST_CALENDARS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_LIST_CALENDAR_EVENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_GET_CALENDAR_EVENT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_LIST_CONTACTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_LIST_MAIL_FOLDERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "OUTLOOK_SEND_EMAIL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_DRAFT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_SEND_DRAFT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_DRAFT_REPLY", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_ME_FORWARD_DRAFT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CALENDAR_CREATE_EVENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_CONTACT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_MAIL_FOLDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "OUTLOOK_DELETE_MESSAGE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_BATCH_MOVE_MESSAGES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_BATCH_UPDATE_MESSAGES", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_ACCEPT_EVENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_CANCEL_EVENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_ME_CALENDAR_PERMISSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "OUTLOOK_CREATE_EMAIL_RULE", + scope: ToolScope::Admin, + }, +]; + +// ── linear ────────────────────────────────────────────────────────── +pub const LINEAR_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "LINEAR_LIST_LINEAR_ISSUES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_GET_LINEAR_ISSUE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_LIST_LINEAR_TEAMS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_LIST_LINEAR_PROJECTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_LIST_LINEAR_STATES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_SEARCH_ISSUES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_GET_CYCLES_BY_TEAM_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_LIST_LINEAR_USERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_LIST_LINEAR_LABELS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_GET_LINEAR_PROJECT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "LINEAR_CREATE_LINEAR_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_UPDATE_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_CREATE_LINEAR_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_CREATE_ATTACHMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_CREATE_LINEAR_PROJECT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_CREATE_LINEAR_LABEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_UPDATE_LINEAR_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_CREATE_ISSUE_RELATION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_UPDATE_LINEAR_PROJECT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "LINEAR_DELETE_LINEAR_ISSUE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "LINEAR_REMOVE_ISSUE_LABEL", + scope: ToolScope::Admin, + }, +]; + +// ── jira ──────────────────────────────────────────────────────────── +pub const JIRA_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "JIRA_GET_ISSUE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_ALL_PROJECTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_FETCH_BULK_ISSUES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_ISSUE_TYPES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_PROJECT_ROLES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_FIND_USERS2", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_FIELDS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_ISSUE_EDIT_METADATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_GET_PROJECT_VERSIONS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "JIRA_CREATE_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_BULK_CREATE_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_EDIT_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_ADD_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_ASSIGN_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_ADD_ATTACHMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_CREATE_ISSUE_LINK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_ADD_WORKLOG", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_TRANSITION_ISSUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "JIRA_DELETE_ISSUE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "JIRA_DELETE_COMMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "JIRA_DELETE_VERSION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "JIRA_DELETE_WORKLOG", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "JIRA_CREATE_PROJECT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "JIRA_ADD_USERS_TO_PROJECT_ROLE", + scope: ToolScope::Admin, + }, +]; + +// ── trello ────────────────────────────────────────────────────────── +pub const TRELLO_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "TRELLO_GET_BOARDS_BY_ID_BOARD", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TRELLO_GET_ACTIONS_BY_ID_ACTION", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TRELLO_GET_BATCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TRELLO_GET_BOARDS_ACTIONS_BY_ID_BOARD", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TRELLO_GET_MEMBERS_BOARDS_BY_ID_MEMBER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TRELLO_ADD_CARDS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_BOARDS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_LISTS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_CARDS_ACTIONS_COMMENTS_BY_ID_CARD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_MEMBER_TO_CARD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_CREATE_CARD_LABEL", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_CARDS_ATTACHMENTS_BY_ID_CARD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_ADD_CARDS_CHECKLISTS_BY_ID_CARD", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_CREATE_WEBHOOK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TRELLO_DELETE_CARDS_BY_ID_CARD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_BOARD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_CHECKLISTS_BY_ID_CHECKLIST", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_ARCHIVE_ALL_LIST_CARDS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_CARD_COMMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_LABELS_BY_ID_LABEL", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_ORGANIZATIONS_BY_ID_ORG", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TRELLO_DELETE_WEBHOOKS_BY_ID_WEBHOOK", + scope: ToolScope::Admin, + }, +]; + +// ── asana ─────────────────────────────────────────────────────────── +pub const ASANA_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "ASANA_GET_A_TASK", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_A_PROJECT", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_MULTIPLE_TASKS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_MULTIPLE_PROJECTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_CURRENT_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_MULTIPLE_WORKSPACES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_PORTFOLIO", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_GOALS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_GET_CUSTOM_FIELDS_FOR_WORKSPACE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "ASANA_CREATE_A_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_CREATE_A_PROJECT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_CREATE_SUBTASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_CREATE_TASK_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_UPDATE_A_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_ADD_FOLLOWERS_TO_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_ADD_TAG_TO_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_ADD_PROJECT_FOR_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_ADD_TASK_DEPENDENCIES", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_CREATE_ATTACHMENT_FOR_TASK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "ASANA_DELETE_TASK", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ASANA_DELETE_PROJECT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ASANA_DELETE_SECTION", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ASANA_DELETE_TAG", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ASANA_DELETE_CUSTOM_FIELD", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "ASANA_DELETE_MEMBERSHIP", + scope: ToolScope::Admin, + }, +]; + +// ── dropbox ───────────────────────────────────────────────────────── +pub const DROPBOX_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "DROPBOX_GET_METADATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_FILES_SEARCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_LIST_FILE_MEMBERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_GET_SHARED_LINK_METADATA", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_GET_ABOUT_ME", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_GET_SPACE_USAGE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "DROPBOX_ALPHA_UPLOAD_FILE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "DROPBOX_CREATE_FOLDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "DROPBOX_COPY_FILE_OR_FOLDER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "DROPBOX_CREATE_SHARED_LINK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "DROPBOX_ADD_FILE_MEMBER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "DROPBOX_DELETE_FILE", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "DROPBOX_DELETE_BATCH", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "DROPBOX_ADD_TEAM_MEMBERS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "DROPBOX_CREATE_TEAM_FOLDER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "DROPBOX_ARCHIVE_TEAM_FOLDER", + scope: ToolScope::Admin, + }, +]; diff --git a/src/openhuman/composio/providers/catalogs_social_media.rs b/src/openhuman/composio/providers/catalogs_social_media.rs new file mode 100644 index 000000000..a05c2b409 --- /dev/null +++ b/src/openhuman/composio/providers/catalogs_social_media.rs @@ -0,0 +1,248 @@ +//! Curated catalogs — social media / entertainment toolkits: Twitter, +//! Spotify, YouTube. + +use super::tool_scope::{CuratedTool, ToolScope}; + +// ── twitter ───────────────────────────────────────────────────────── +pub const TWITTER_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "TWITTER_RECENT_SEARCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_GET_USER_BY_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_POST_LOOKUP_BY_POST_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_FOLLOWERS_BY_USER_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_FOLLOWING_BY_USER_ID", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_BOOKMARKS_BY_USER", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_GET_LIST_MEMBERS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_FULL_ARCHIVE_SEARCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "TWITTER_CREATION_OF_A_POST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_RETWEET_POST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_ADD_POST_TO_BOOKMARKS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_FOLLOW_USER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_MUTE_USER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_CREATE_DM_CONVERSATION", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_CREATE_LIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_ADD_LIST_MEMBER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "TWITTER_POST_DELETE_BY_POST_ID", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TWITTER_DELETE_LIST", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TWITTER_REMOVE_LIST_MEMBER", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TWITTER_DELETE_DM", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "TWITTER_REMOVE_POST_FROM_BOOKMARKS", + scope: ToolScope::Admin, + }, +]; + +// ── spotify ───────────────────────────────────────────────────────── +pub const SPOTIFY_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "SPOTIFY_GET_CURRENT_USER_S_PROFILE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_USER_S_TOP_TRACKS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_PLAYLIST", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_PLAYLIST_ITEMS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_RECENTLY_PLAYED_TRACKS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_USER_S_SAVED_TRACKS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_SEARCH_FOR_ITEM", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_GET_AVAILABLE_DEVICES", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "SPOTIFY_ADD_ITEMS_TO_PLAYLIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_CREATE_PLAYLIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_SAVE_TRACKS_FOR_CURRENT_USER", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_PAUSE_PLAYBACK", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_ADD_ITEM_TO_PLAYBACK_QUEUE", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_CHANGE_PLAYLIST_DETAILS", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "SPOTIFY_REMOVE_PLAYLIST_ITEMS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SPOTIFY_REMOVE_USER_S_SAVED_TRACKS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SPOTIFY_UNFOLLOW_ARTISTS_OR_USERS", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "SPOTIFY_REMOVE_USER_S_SAVED_ALBUMS", + scope: ToolScope::Admin, + }, +]; + +// ── youtube ───────────────────────────────────────────────────────── +pub const YOUTUBE_CURATED: &[CuratedTool] = &[ + CuratedTool { + slug: "YOUTUBE_SEARCH_YOU_TUBE", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_LIST_CHANNEL_VIDEOS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_GET_CHANNEL_STATISTICS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_LIST_COMMENT_THREADS2", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_LIST_COMMENTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_GET_VIDEO_DETAILS_BATCH", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_LIST_USER_PLAYLISTS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_LIST_PLAYLIST_ITEMS", + scope: ToolScope::Read, + }, + CuratedTool { + slug: "YOUTUBE_UPLOAD_VIDEO", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_UPDATE_VIDEO", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_CREATE_PLAYLIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_ADD_VIDEO_TO_PLAYLIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_POST_COMMENT", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_RATE_VIDEO", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_UPDATE_PLAYLIST", + scope: ToolScope::Write, + }, + CuratedTool { + slug: "YOUTUBE_DELETE_VIDEO", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "YOUTUBE_DELETE_PLAYLIST", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "YOUTUBE_DELETE_COMMENT", + scope: ToolScope::Admin, + }, + CuratedTool { + slug: "YOUTUBE_DELETE_PLAYLIST_ITEM", + scope: ToolScope::Admin, + }, +]; diff --git a/src/openhuman/composio/providers/mod.rs b/src/openhuman/composio/providers/mod.rs index de9ec1b57..26269e3e5 100644 --- a/src/openhuman/composio/providers/mod.rs +++ b/src/openhuman/composio/providers/mod.rs @@ -40,6 +40,11 @@ mod types; pub mod user_scopes; pub mod catalogs; +pub mod catalogs_business; +pub mod catalogs_google; +pub mod catalogs_messaging; +pub mod catalogs_productivity; +pub mod catalogs_social_media; pub mod github; pub mod gmail; pub mod notion; diff --git a/src/openhuman/memory/ingestion.rs b/src/openhuman/memory/ingestion.rs index 0f75a57b7..25dc71620 100644 --- a/src/openhuman/memory/ingestion.rs +++ b/src/openhuman/memory/ingestion.rs @@ -11,1545 +11,27 @@ //! 5. **Persistence**: Upserting the document, text chunks, and graph relations into //! the memory store. -use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::sync::OnceLock; +#[path = "ingestion_parse.rs"] +mod ingestion_parse; +#[path = "ingestion_regex.rs"] +mod ingestion_regex; +#[path = "ingestion_rules.rs"] +mod ingestion_rules; +#[path = "ingestion_types.rs"] +mod ingestion_types; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; +pub use ingestion_types::{ + ExtractedEntity, ExtractedRelation, ExtractionMode, MemoryIngestionConfig, + MemoryIngestionRequest, MemoryIngestionResult, DEFAULT_MEMORY_EXTRACTION_MODEL, +}; + +use ingestion_parse::{enrich_document_metadata, parse_document}; +use ingestion_types::ParsedIngestion; +use serde_json::json; use crate::openhuman::memory::store::types::NamespaceDocumentInput; use crate::openhuman::memory::UnifiedMemory; -/// Default extraction backend label reported in ingestion metadata. -pub const DEFAULT_MEMORY_EXTRACTION_MODEL: &str = "heuristic-only"; -/// Default number of tokens per text chunk during ingestion. -const DEFAULT_CHUNK_TOKENS: usize = 225; - -/// Granularity of extraction for heuristic parsing. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[derive(Default)] -pub enum ExtractionMode { - /// Extract from each individual sentence (higher precision). - #[default] - Sentence, - /// Extract from the entire chunk at once (faster, better for context). - Chunk, -} - -impl ExtractionMode { - /// Returns the string representation of the extraction mode. - fn as_str(self) -> &'static str { - match self { - Self::Sentence => "sentence", - Self::Chunk => "chunk", - } - } -} - -/// Configuration for the memory ingestion process. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MemoryIngestionConfig { - /// Extraction backend label recorded in metadata/results. - pub model_name: String, - /// The granularity of heuristic extraction. - #[serde(default)] - pub extraction_mode: ExtractionMode, - /// Minimum confidence threshold for entity extraction (0.0 to 1.0). - #[serde(default = "default_entity_threshold")] - pub entity_threshold: f32, - /// Minimum confidence threshold for relation extraction (0.0 to 1.0). - #[serde(default = "default_relation_threshold")] - pub relation_threshold: f32, - /// Threshold for adjacency-based heuristics. - #[serde(default = "default_adjacency_threshold")] - pub adjacency_threshold: f32, - /// Reserved batch-size knob kept for config compatibility. - #[serde(default = "default_batch_size")] - pub batch_size: usize, -} - -fn default_entity_threshold() -> f32 { - 0.45 -} - -fn default_relation_threshold() -> f32 { - 0.30 -} - -fn default_adjacency_threshold() -> f32 { - 0.50 -} - -fn default_batch_size() -> usize { - 16 -} - -impl Default for MemoryIngestionConfig { - fn default() -> Self { - Self { - model_name: DEFAULT_MEMORY_EXTRACTION_MODEL.to_string(), - extraction_mode: ExtractionMode::Sentence, - entity_threshold: default_entity_threshold(), - relation_threshold: default_relation_threshold(), - adjacency_threshold: default_adjacency_threshold(), - batch_size: default_batch_size(), - } - } -} - -/// A request to ingest a single document. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MemoryIngestionRequest { - /// The document input to process. - pub document: NamespaceDocumentInput, - /// Ingestion configuration. - #[serde(default)] - pub config: MemoryIngestionConfig, -} - -/// An entity identified during the ingestion process. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ExtractedEntity { - /// Normalized name of the entity (all-caps). - pub name: String, - /// Classification (e.g., PERSON, ORGANIZATION). - pub entity_type: String, - /// Known aliases for this entity. - #[serde(default)] - pub aliases: Vec, -} - -/// A relation identified during the ingestion process. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ExtractedRelation { - /// Name of the subject entity. - pub subject: String, - /// Classification of the subject. - pub subject_type: String, - /// Relationship type (e.g., OWNS, WORKS_ON). - pub predicate: String, - /// Name of the object entity. - pub object: String, - /// Classification of the object. - pub object_type: String, - /// Extraction confidence (0.0 to 1.0). - pub confidence: f32, - /// Number of distinct occurrences of this relation. - pub evidence_count: u32, - /// IDs of the chunks where this relation was found. - pub chunk_ids: Vec, - /// Sequential order index for reconstruction. - pub order_index: Option, - /// Additional metadata about the extraction. - pub metadata: Value, -} - -/// The comprehensive result of an ingestion operation. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MemoryIngestionResult { - /// ID of the document that was ingested. - pub document_id: String, - /// Namespace containing the document. - pub namespace: String, - /// Extraction backend label recorded for the ingestion run. - pub model_name: String, - /// Mode used for extraction. - pub extraction_mode: String, - /// Total number of chunks processed. - pub chunk_count: usize, - /// Total number of distinct entities found. - pub entity_count: usize, - /// Total number of distinct relations found. - pub relation_count: usize, - /// Number of identified user preferences. - pub preference_count: usize, - /// Number of identified decisions. - pub decision_count: usize, - /// Auto-generated tags for the document. - #[serde(default)] - pub tags: Vec, - /// Complete list of identified entities. - #[serde(default)] - pub entities: Vec, - /// Complete list of identified relations. - #[serde(default)] - pub relations: Vec, -} - -/// Intermediate representation of an entity before normalization and alias resolution. -#[derive(Debug, Clone)] -struct RawEntity { - name: String, - entity_type: String, - confidence: f32, -} - -/// Intermediate representation of a relationship before aggregation. -#[derive(Debug, Clone)] -struct RawRelation { - subject: String, - subject_type: String, - predicate: String, - object: String, - object_type: String, - confidence: f32, - /// Indices of the chunks where this relation was found. - chunk_indexes: BTreeSet, - /// Global sequential index for ordering within the document. - order_index: i64, - /// JSON metadata for the relation. - metadata: Map, -} - -/// A single unit of text (sentence or chunk) passed to the extractor. -#[derive(Debug, Clone)] -struct ExtractionUnit { - text: String, - chunk_index: usize, - order_index: i64, -} - -/// Accumulates extraction results across multiple chunks or units. -/// -/// Handles entity and relation deduplication, alias tracking, and -/// basic document understanding (e.g., identifying the primary subject). -#[derive(Debug, Default)] -struct ExtractionAccumulator { - /// Mapping of normalized entity name to its highest-confidence raw extraction. - entities: HashMap, - /// Collected relations before final canonicalization. - relations: Vec, - /// Tags identified during processing. - tags: BTreeSet, - /// Decisions identified during processing. - decisions: BTreeSet, - /// User preferences identified during processing. - preferences: BTreeSet, - /// Inferred document kind (e.g., "profile"). - doc_kind: Option, - /// The document's inferred primary subject. - primary_subject: Option, - /// Sanitized document title. - document_title: Option, - /// The subject of the current markdown section. - current_subject: Option, - /// Current sender if processing a message/thread. - current_sender: Option, - /// Mapping of names to their canonicalized full name. - known_people: HashMap, -} - -/// The result of the parsing stage of ingestion. -#[derive(Debug)] -struct ParsedIngestion { - tags: Vec, - metadata: Value, - entities: Vec, - relations: Vec, - chunk_count: usize, - preference_count: usize, - decision_count: usize, -} - -/// A validation rule for semantic relationships. -#[derive(Debug)] -struct RelationRule { - /// Canonical predicate name (uppercase snake_case). - canonical: &'static str, - /// Allowed classifications for the subject. - allowed_head: &'static [&'static str], - /// Allowed classifications for the object. - allowed_tail: &'static [&'static str], -} - -const PERSON_TYPES: &[&str] = &["PERSON"]; -const ORG_TYPES: &[&str] = &[ - "ORGANIZATION", - "PROJECT", - "PRODUCT", - "TOOL", - "TOPIC", - "WORK_ITEM", -]; -const PLACE_TYPES: &[&str] = &["PLACE", "LOCATION", "ROOM"]; -const DATE_TYPES: &[&str] = &["DATE"]; - -/// Returns the semantic validation rule for a given predicate name. -fn relation_rule(predicate: &str) -> Option { - let normalized = UnifiedMemory::normalize_graph_predicate(predicate); - let rule = match normalized.as_str() { - "OWNS" | "WORKS_ON" | "RESPONSIBLE_FOR" | "REVIEWS" => RelationRule { - canonical: "OWNS", - allowed_head: PERSON_TYPES, - allowed_tail: ORG_TYPES, - }, - "USES" | "KEEPS" | "ADOPTS" => RelationRule { - canonical: "USES", - allowed_head: ORG_TYPES, - allowed_tail: ORG_TYPES, - }, - "WORKS_FOR" => RelationRule { - canonical: "WORKS_FOR", - allowed_head: PERSON_TYPES, - allowed_tail: &["ORGANIZATION", "PROJECT", "PRODUCT"], - }, - "DEPENDS_ON" => RelationRule { - canonical: "DEPENDS_ON", - allowed_head: ORG_TYPES, - allowed_tail: ORG_TYPES, - }, - "PREFERS" => RelationRule { - canonical: "PREFERS", - allowed_head: PERSON_TYPES, - allowed_tail: &["TOPIC", "WORK_ITEM", "MODE", "PRODUCT", "TOOL"], - }, - "HAS_DEADLINE" | "DUE_ON" => RelationRule { - canonical: "HAS_DEADLINE", - allowed_head: ORG_TYPES, - allowed_tail: DATE_TYPES, - }, - "COMMUNICATES_WITH" => RelationRule { - canonical: "COMMUNICATES_WITH", - allowed_head: PERSON_TYPES, - allowed_tail: PERSON_TYPES, - }, - "INVESTIGATES" | "EVALUATES" => RelationRule { - canonical: "INVESTIGATES", - allowed_head: PERSON_TYPES, - allowed_tail: ORG_TYPES, - }, - "NORTH_OF" => RelationRule { - canonical: "NORTH_OF", - allowed_head: PLACE_TYPES, - allowed_tail: PLACE_TYPES, - }, - "SOUTH_OF" => RelationRule { - canonical: "SOUTH_OF", - allowed_head: PLACE_TYPES, - allowed_tail: PLACE_TYPES, - }, - "EAST_OF" => RelationRule { - canonical: "EAST_OF", - allowed_head: PLACE_TYPES, - allowed_tail: PLACE_TYPES, - }, - "WEST_OF" => RelationRule { - canonical: "WEST_OF", - allowed_head: PLACE_TYPES, - allowed_tail: PLACE_TYPES, - }, - "AVOIDS" => RelationRule { - canonical: "AVOIDS", - allowed_head: ORG_TYPES, - allowed_tail: ORG_TYPES, - }, - _ => return None, - }; - Some(rule) -} - -/// Helper to check if a classification is allowed by a rule. -fn type_allowed(actual: &str, allowed: &[&str]) -> bool { - allowed.is_empty() || allowed.iter().any(|candidate| candidate == &actual) -} - -/// Resolves a person's name using the known alias map. -fn resolve_person_alias(name: &str, known_people: &HashMap) -> String { - let upper = name.to_uppercase(); - known_people.get(&upper).cloned().unwrap_or(upper) -} - -impl ExtractionAccumulator { - /// Ingests a full name and its components (e.g., first name) into the alias map. - fn remember_person_aliases(&mut self, canonical_name: &str) { - let parts = canonical_name.split_whitespace().collect::>(); - if let Some(first_name) = parts.first() { - self.known_people - .entry(first_name.to_uppercase()) - .or_insert_with(|| canonical_name.to_string()); - } - } - - /// Records a new entity, updating confidence if already known. - fn add_entity(&mut self, name: &str, entity_type: &str, confidence: f32) -> Option { - let cleaned = sanitize_entity_name(name); - if cleaned.is_empty() { - return None; - } - let resolved_name = if entity_type == "PERSON" { - resolve_person_alias(&cleaned, &self.known_people) - } else { - cleaned.clone() - }; - let entry = self - .entities - .entry(resolved_name.clone()) - .or_insert_with(|| RawEntity { - name: resolved_name.clone(), - entity_type: entity_type.to_string(), - confidence, - }); - if confidence > entry.confidence { - entry.confidence = confidence; - } - if entity_type == "PERSON" { - self.remember_person_aliases(&resolved_name); - } - Some(resolved_name) - } - - /// Records a new relationship, applying semantic validation rules. - #[allow(clippy::too_many_arguments)] - fn add_relation( - &mut self, - subject: &str, - subject_type: &str, - predicate: &str, - object: &str, - object_type: &str, - confidence: f32, - chunk_index: usize, - order_index: i64, - metadata: Map, - ) { - let Some(rule) = relation_rule(predicate) else { - return; - }; - let Some(subject_name) = self.add_entity(subject, subject_type, confidence) else { - return; - }; - let Some(object_name) = self.add_entity(object, object_type, confidence) else { - return; - }; - if subject_name == object_name { - return; - } - let actual_subject_type = self - .entities - .get(&subject_name) - .map(|value| value.entity_type.as_str()) - .unwrap_or(subject_type); - let actual_object_type = self - .entities - .get(&object_name) - .map(|value| value.entity_type.as_str()) - .unwrap_or(object_type); - if !type_allowed(actual_subject_type, rule.allowed_head) - || !type_allowed(actual_object_type, rule.allowed_tail) - { - return; - } - - let mut chunk_indexes = BTreeSet::new(); - chunk_indexes.insert(chunk_index); - self.relations.push(RawRelation { - subject: subject_name, - subject_type: actual_subject_type.to_string(), - predicate: rule.canonical.to_string(), - object: object_name, - object_type: actual_object_type.to_string(), - confidence, - chunk_indexes, - order_index, - metadata, - }); - } -} - -/// Regex for identifying standard email headers (From, To, Cc). -fn email_header_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX - .get_or_init(|| Regex::new(r"^(From|To|Cc):\s*(?P.+)$").expect("email header regex")) -} - -/// Regex for identifying named email addresses (e.g., "John Doe "). -fn named_email_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"(?P[^,<]+?)\s*<(?P[^>]+)>").expect("named email regex") - }) -} - -/// Regex for identifying explicit graph facts (e.g., "Alice works_on Project-X"). -fn graph_fact_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new( - r"^(?P[A-Za-z0-9][A-Za-z0-9 ._\-/]+?)\s+(?Pworks_on|depends_on|uses|evaluates|owns|prefers)\s+(?P.+)$", - ) - .expect("graph fact regex") - }) -} - -/// Regex for identifying ownership patterns (e.g., "Bob owns the repository"). -fn explicit_owner_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"^(?P[A-Za-z][A-Za-z ._-]+?) owns (?P.+)$") - .expect("explicit owner regex") - }) -} - -/// Regex for identifying preference patterns (e.g., "Carol prefers light mode"). -fn explicit_preference_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"^(?P[A-Za-z][A-Za-z ._-]+?) prefers (?P.+)$") - .expect("explicit preference regex") - }) -} - -/// Regex for identifying action items or assignments (e.g., "Dave: finish the API"). -fn action_item_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"^(?P[A-Za-z][A-Za-z ._-]+?):\s*(?P.+)$") - .expect("action item regex") - }) -} - -/// Regex for identifying review assignments. -fn will_review_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"^(?P[A-Za-z][A-Za-z ._-]+?) will review (?P.+)$") - .expect("will review regex") - }) -} - -/// Regex for identifying complex giving/receiving interactions. -fn recipient_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new( - r"(?i)(?P[A-Z][A-Za-z]+(?: [A-Z][A-Za-z]+)?)\s+(gave|sent|handed|passed)\s+(?P.+?)\s+to\s+(?P[A-Z][A-Za-z]+(?: [A-Z][A-Za-z]+)?)", - ) - .expect("recipient regex") - }) -} - -/// Regex for identifying spatial relationships (e.g., "Kitchen is north of the Garden"). -fn spatial_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new( - r"(?i)(?P[A-Za-z][A-Za-z0-9 _-]+?)\s+is\s+(?Pnorth|south|east|west)\s+of\s+(?P[A-Za-z][A-Za-z0-9 _-]+)", - ) - .expect("spatial regex") - }) -} - -/// Regex for identifying dates in "Month DD, YYYY" format. -fn month_date_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"(?i)\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\s+\d{1,2},\s+\d{4}\b") - .expect("month date regex") - }) -} - -/// Regex for identifying ISO-8601 dates (YYYY-MM-DD). -fn iso_date_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX.get_or_init(|| Regex::new(r"\b\d{4}-\d{2}-\d{2}\b").expect("iso date regex")) -} - -/// Regex for identifying potential person names (Title Case). -fn person_name_regex() -> &'static Regex { - static REGEX: OnceLock = OnceLock::new(); - REGEX - .get_or_init(|| Regex::new(r"\b[A-Z][a-z]+(?: [A-Z][a-z]+)+\b").expect("person name regex")) -} - -/// Normalizes an entity name by trimming punctuation, collapsing whitespace, and converting to uppercase. -fn sanitize_entity_name(name: &str) -> String { - let trimmed = name.trim().trim_matches(|ch: char| { - matches!(ch, '-' | ':' | ';' | ',' | '.' | '"' | '\'' | '(' | ')') - }); - if trimmed.is_empty() { - return String::new(); - } - UnifiedMemory::collapse_whitespace(trimmed).to_uppercase() -} - -/// Normalizes text content by trimming and collapsing whitespace. -fn sanitize_fact_text(text: &str) -> String { - let trimmed = text - .trim() - .trim_start_matches('-') - .trim() - .trim_matches(|ch: char| matches!(ch, ':' | ';' | ',' | '.')); - UnifiedMemory::collapse_whitespace(trimmed) -} - -/// Heuristically classifies an entity based on its name and known person map. -fn classify_entity(name: &str, known_people: &HashMap) -> &'static str { - let upper = sanitize_entity_name(name); - if upper.is_empty() { - return "TOPIC"; - } - if month_date_regex().is_match(name) || iso_date_regex().is_match(name) { - return "DATE"; - } - if upper.contains('@') { - return "ORGANIZATION"; - } - if known_people.contains_key(&upper) || person_name_regex().is_match(name) { - return "PERSON"; - } - if matches!( - upper.as_str(), - "OPENHUMAN" | "JSON-RPC" | "JSON-RPC 2.0" | "NEOCORTEX_V2" | "NEOCORTEX V2" - ) { - return "PRODUCT"; - } - if upper.contains("MODEL") { - return "TOOL"; - } - if upper.contains("MODE") { - return "MODE"; - } - if upper.contains("MILESTONE") - || upper.contains("ROADMAP") - || upper.contains("CONTRACT") - || upper.contains("API") - || upper.contains("MEMORY") - || upper.contains("FIXTURE") - || upper.contains("THREAD") - || upper.contains("WORK") - { - return "WORK_ITEM"; - } - if upper.contains("OFFICE") - || upper.contains("ROOM") - || upper.contains("GARDEN") - || upper.contains("KITCHEN") - { - return "ROOM"; - } - if upper.contains("TINYHUMANS") || upper.ends_with("CORE") { - return "ORGANIZATION"; - } - if (upper.contains('-') || upper.contains('_')) && !upper.contains(' ') { - return "PROJECT"; - } - "TOPIC" -} - -/// Splits a document into individual sentences based on punctuation and line breaks. -fn split_sentences(text: &str) -> Vec { - let mut out = Vec::new(); - let mut current = String::new(); - for ch in text.chars() { - current.push(ch); - if matches!(ch, '.' | '!' | '?' | '\n') { - let candidate = sanitize_fact_text(¤t); - if !candidate.is_empty() { - out.push(candidate); - } - current.clear(); - } - } - let tail = sanitize_fact_text(¤t); - if !tail.is_empty() { - out.push(tail); - } - let mut merged: Vec = Vec::new(); - for sentence in out { - if sentence.len() < 5 && !merged.is_empty() { - if let Some(last) = merged.last_mut() { - last.push(' '); - last.push_str(&sentence); - } - } else { - merged.push(sentence); - } - } - if merged.is_empty() && !text.trim().is_empty() { - merged.push(sanitize_fact_text(text)); - } - merged -} - -/// Groups chunks into extraction units based on the configured mode. -fn build_units(chunks: &[String], mode: ExtractionMode) -> Vec { - let mut units = Vec::new(); - let mut order_index = 0_i64; - for (chunk_index, chunk) in chunks.iter().enumerate() { - match mode { - ExtractionMode::Chunk => { - let text = sanitize_fact_text(chunk); - if text.is_empty() { - continue; - } - units.push(ExtractionUnit { - text, - chunk_index, - order_index, - }); - order_index += 1; - } - ExtractionMode::Sentence => { - for sentence in split_sentences(chunk) { - if sentence.is_empty() { - continue; - } - units.push(ExtractionUnit { - text: sentence, - chunk_index, - order_index, - }); - order_index += 1; - } - } - } - } - units -} - -/// Searches for the chunk index that most likely contains the given excerpt. -fn find_chunk_index(chunks: &[String], excerpt: &str, hint: usize) -> usize { - if chunks.is_empty() { - return 0; - } - let needle = UnifiedMemory::normalize_search_text(excerpt); - if needle.is_empty() { - return hint.min(chunks.len().saturating_sub(1)); - } - for (index, chunk) in chunks.iter().enumerate().skip(hint) { - if UnifiedMemory::normalize_search_text(chunk).contains(&needle) { - return index; - } - } - for (index, chunk) in chunks.iter().enumerate().take(hint.min(chunks.len())) { - if UnifiedMemory::normalize_search_text(chunk).contains(&needle) { - return index; - } - } - hint.min(chunks.len().saturating_sub(1)) -} - -fn extract_people_from_header(value: &str, accumulator: &mut ExtractionAccumulator) -> Vec { - let mut people = Vec::new(); - for captures in named_email_regex().captures_iter(value) { - let name = sanitize_fact_text( - captures - .name("name") - .map(|value| value.as_str()) - .unwrap_or(""), - ); - if name.is_empty() { - continue; - } - let canonical = sanitize_entity_name(&name); - let _ = accumulator.add_entity(&canonical, "PERSON", 0.95); - accumulator.remember_person_aliases(&canonical); - people.push(canonical); - } - people -} - -fn detect_primary_subject(text: &str) -> Option { - if text.contains("OpenHuman") { - return Some("OPENHUMAN".to_string()); - } - None -} - -fn enrich_document_metadata( - input: &NamespaceDocumentInput, - parsed: &ParsedIngestion, - config: &MemoryIngestionConfig, -) -> (NamespaceDocumentInput, Vec) { - let mut metadata = match input.metadata.clone() { - Value::Object(map) => map, - _ => Map::new(), - }; - for (key, value) in parsed.metadata.as_object().cloned().unwrap_or_default() { - metadata.insert(key, value); - } - metadata.insert( - "ingestion".to_string(), - json!({ - "backend": "openhuman_rust_heuristic", - "model_name": config.model_name, - "extraction_mode": config.extraction_mode.as_str(), - "entity_count": parsed.entities.len(), - "relation_count": parsed.relations.len(), - "preference_count": parsed.preference_count, - "decision_count": parsed.decision_count, - "chunk_count": parsed.chunk_count, - }), - ); - if parsed.preference_count > 0 || parsed.decision_count > 0 { - metadata.insert("kind".to_string(), json!("profile")); - } - - let mut tags = input.tags.iter().cloned().collect::>(); - tags.extend(parsed.tags.iter().cloned()); - let tags = tags.into_iter().collect::>(); - - ( - NamespaceDocumentInput { - namespace: input.namespace.clone(), - key: input.key.clone(), - title: input.title.clone(), - content: input.content.clone(), - source_type: input.source_type.clone(), - priority: input.priority.clone(), - tags: tags.clone(), - metadata: Value::Object(metadata), - category: input.category.clone(), - session_id: input.session_id.clone(), - document_id: input.document_id.clone(), - }, - tags, - ) -} - -fn reverse_aliases(aliases: &HashMap) -> BTreeMap> { - let mut reverse = BTreeMap::new(); - for (alias, canonical) in aliases { - if alias == canonical { - continue; - } - reverse - .entry(canonical.clone()) - .or_insert_with(Vec::new) - .push(alias.clone()); - } - for values in reverse.values_mut() { - values.sort(); - values.dedup(); - } - reverse -} - -fn build_alias_map(entities: &HashMap) -> HashMap { - let mut by_type = HashMap::>::new(); - for entity in entities.values() { - by_type - .entry(entity.entity_type.clone()) - .or_default() - .push(entity.name.clone()); - } - - let mut aliases = HashMap::new(); - for names in by_type.values_mut() { - names.sort_by_key(|name| std::cmp::Reverse(name.len())); - for short in names.iter() { - for long in names.iter() { - if short == long || long.len() <= short.len() { - continue; - } - if long.starts_with(&format!("{short} ")) || long.ends_with(&format!(" {short}")) { - aliases.entry(short.clone()).or_insert_with(|| long.clone()); - break; - } - } - } - } - aliases -} - -fn resolve_alias(name: &str, aliases: &HashMap) -> String { - let mut current = name.to_string(); - let mut seen = BTreeSet::new(); - while let Some(next) = aliases.get(¤t) { - if !seen.insert(current.clone()) { - break; - } - current = next.clone(); - } - current -} - -async fn parse_document( - content: &str, - title: &str, - config: &MemoryIngestionConfig, -) -> ParsedIngestion { - let chunks = UnifiedMemory::chunk_document_content(content, DEFAULT_CHUNK_TOKENS); - log::info!( - "[memory:ingestion] parse_document title={title:?} model={} \ - content_len={} chunk_count={} — heuristic extraction active", - config.model_name, - content.len(), - chunks.len(), - ); - let mut accumulator = ExtractionAccumulator { - document_title: Some(sanitize_entity_name(title)), - primary_subject: detect_primary_subject(title), - ..ExtractionAccumulator::default() - }; - - let mut chunk_hint = 0_usize; - for raw_line in content.lines() { - let line = sanitize_fact_text(raw_line); - if line.is_empty() { - continue; - } - - let chunk_index = find_chunk_index(&chunks, &line, chunk_hint); - chunk_hint = chunk_index; - let order_index = i64::try_from(chunk_index).unwrap_or(i64::MAX); - - if raw_line.trim_start().starts_with('#') { - let heading = sanitize_entity_name(raw_line.trim_start_matches('#')); - if !heading.is_empty() { - if accumulator.document_title.is_none() { - accumulator.document_title = Some(heading.clone()); - } - accumulator.current_subject = Some(heading); - } - continue; - } - - if let Some(captures) = email_header_regex().captures(&line) { - let header_name = captures - .get(1) - .map(|value| value.as_str()) - .unwrap_or_default() - .to_ascii_uppercase(); - let value = captures - .name("value") - .map(|value| value.as_str()) - .unwrap_or(""); - let people = extract_people_from_header(value, &mut accumulator); - if header_name == "FROM" { - accumulator.current_sender = people.first().cloned(); - } else if header_name == "TO" || header_name == "CC" { - if let Some(sender) = accumulator.current_sender.clone() { - for recipient in &people { - accumulator.add_relation( - &sender, - "PERSON", - "communicates_with", - recipient, - "PERSON", - 0.82, - chunk_index, - order_index, - Map::new(), - ); - } - } - } - continue; - } - - if let Some(subject) = line.strip_prefix("Subject:") { - let subject_text = sanitize_fact_text(subject); - if let Some(primary_subject) = detect_primary_subject(&subject_text) { - accumulator.primary_subject = Some(primary_subject); - } - continue; - } - - if let Some(date_text) = line.strip_prefix("Date:") { - let date_text = sanitize_fact_text(date_text); - if let Some(sender) = accumulator.current_sender.clone() { - accumulator.add_relation( - &sender, - "PERSON", - "has_deadline", - &date_text, - "DATE", - 0.75, - chunk_index, - order_index, - Map::new(), - ); - } - continue; - } - - if let Some(value) = line.strip_prefix("Project name:") { - let project = sanitize_entity_name(value); - if !project.is_empty() { - accumulator.primary_subject = Some(project.clone()); - let _ = accumulator.add_entity(&project, "PROJECT", 0.96); - } - continue; - } - - if let Some(value) = line.strip_prefix("Subproject:") { - let subproject = sanitize_entity_name(value); - if !subproject.is_empty() { - let _ = accumulator.add_entity(&subproject, "PROJECT", 0.92); - } - continue; - } - - if let Some(value) = line.strip_prefix("Owner:") { - let owner = sanitize_entity_name(value); - let owned = accumulator - .current_subject - .clone() - .or_else(|| accumulator.primary_subject.clone()) - .or_else(|| accumulator.document_title.clone()) - .unwrap_or_else(|| "DOCUMENT".to_string()); - accumulator.add_relation( - &owner, - "PERSON", - "owns", - &owned, - "WORK_ITEM", - 0.94, - chunk_index, - order_index, - Map::new(), - ); - continue; - } - - if let Some(value) = line.strip_prefix("Name:") { - let name = sanitize_entity_name(value); - if !name.is_empty() { - accumulator.current_subject = Some(name.clone()); - let _ = accumulator.add_entity(&name, "WORK_ITEM", 0.93); - } - continue; - } - - if let Some(value) = line.strip_prefix("Due date:") { - let due_date = sanitize_fact_text(value); - let subject = accumulator - .current_subject - .clone() - .or_else(|| accumulator.primary_subject.clone()) - .unwrap_or_else(|| "DOCUMENT".to_string()); - accumulator.add_relation( - &subject, - "WORK_ITEM", - "has_deadline", - &due_date, - "DATE", - 0.92, - chunk_index, - order_index, - Map::new(), - ); - accumulator.tags.insert("deadline".to_string()); - continue; - } - - if let Some(value) = line.strip_prefix("Target milestone:") { - let due_date = sanitize_fact_text(value); - let subject = accumulator - .primary_subject - .clone() - .or_else(|| accumulator.document_title.clone()) - .unwrap_or_else(|| "DOCUMENT".to_string()); - accumulator.add_relation( - &subject, - "PROJECT", - "has_deadline", - &due_date, - "DATE", - 0.92, - chunk_index, - order_index, - Map::new(), - ); - accumulator.tags.insert("deadline".to_string()); - continue; - } - - if let Some(value) = line.strip_prefix("Preferred embedding model for local experiments:") { - let model = sanitize_fact_text(value); - let subject = accumulator - .primary_subject - .clone() - .or_else(|| accumulator.document_title.clone()) - .unwrap_or_else(|| "DOCUMENT".to_string()); - accumulator.add_relation( - &subject, - "PROJECT", - "uses", - &model, - "TOOL", - 0.88, - chunk_index, - order_index, - Map::new(), - ); - accumulator - .decisions - .insert(format!("{subject} uses {model}")); - accumulator.tags.insert("decision".to_string()); - continue; - } - - if let Some(value) = line.strip_prefix("Preferred extraction mode to try first:") { - let mode = sanitize_fact_text(value); - let subject = accumulator - .primary_subject - .clone() - .or_else(|| accumulator.document_title.clone()) - .unwrap_or_else(|| "DOCUMENT".to_string()); - accumulator.add_relation( - &subject, - "PROJECT", - "uses", - &mode, - "MODE", - 0.88, - chunk_index, - order_index, - Map::new(), - ); - accumulator - .decisions - .insert(format!("{subject} uses {mode}")); - accumulator.tags.insert("decision".to_string()); - continue; - } - - if let Some(captures) = graph_fact_regex().captures(&line) { - let subject = captures - .name("subject") - .map(|value| value.as_str()) - .unwrap_or(""); - let predicate = captures - .name("predicate") - .map(|value| value.as_str()) - .unwrap_or(""); - let object = captures - .name("object") - .map(|value| value.as_str()) - .unwrap_or(""); - let subject_type = classify_entity(subject, &accumulator.known_people); - let object_type = classify_entity(object, &accumulator.known_people); - accumulator.add_relation( - subject, - subject_type, - predicate, - object, - object_type, - 0.87, - chunk_index, - order_index, - Map::new(), - ); - if UnifiedMemory::normalize_graph_predicate(predicate) == "PREFERS" { - accumulator.preferences.insert(format!( - "{} prefers {}", - sanitize_entity_name(subject), - sanitize_fact_text(object) - )); - accumulator.tags.insert("preference".to_string()); - accumulator.doc_kind = Some("profile".to_string()); - } - continue; - } - - if let Some(captures) = explicit_owner_regex().captures(&line) { - let subject = captures - .name("subject") - .map(|value| value.as_str()) - .unwrap_or(""); - let object = captures - .name("object") - .map(|value| value.as_str()) - .unwrap_or(""); - accumulator.add_relation( - subject, - "PERSON", - "owns", - object, - classify_entity(object, &accumulator.known_people), - 0.94, - chunk_index, - order_index, - Map::new(), - ); - accumulator.tags.insert("owner".to_string()); - continue; - } - - if let Some(captures) = will_review_regex().captures(&line) { - let subject = captures - .name("subject") - .map(|value| value.as_str()) - .unwrap_or(""); - let object = captures - .name("object") - .map(|value| value.as_str()) - .unwrap_or(""); - accumulator.add_relation( - subject, - "PERSON", - "reviews", - object, - classify_entity(object, &accumulator.known_people), - 0.80, - chunk_index, - order_index, - Map::new(), - ); - accumulator.tags.insert("owner".to_string()); - continue; - } - - if let Some(captures) = explicit_preference_regex().captures(&line) { - let subject = captures - .name("subject") - .map(|value| value.as_str()) - .unwrap_or(""); - let object = captures - .name("object") - .map(|value| value.as_str()) - .unwrap_or(""); - accumulator.add_relation( - subject, - "PERSON", - "prefers", - object, - classify_entity(object, &accumulator.known_people), - 0.90, - chunk_index, - order_index, - Map::new(), - ); - accumulator.preferences.insert(format!( - "{} prefers {}", - sanitize_entity_name(subject), - sanitize_fact_text(object) - )); - accumulator.tags.insert("preference".to_string()); - accumulator.doc_kind = Some("profile".to_string()); - continue; - } - - if let Some(value) = line.strip_prefix("I prefer ") { - if let Some(subject) = accumulator.current_sender.clone() { - let preference = sanitize_fact_text(value); - accumulator.add_relation( - &subject, - "PERSON", - "prefers", - &preference, - classify_entity(&preference, &accumulator.known_people), - 0.92, - chunk_index, - order_index, - Map::new(), - ); - accumulator - .preferences - .insert(format!("{subject} prefers {preference}")); - accumulator.tags.insert("preference".to_string()); - accumulator.doc_kind = Some("profile".to_string()); - continue; - } - } - - if let Some(captures) = action_item_regex().captures(&line) { - let subject = captures - .name("subject") - .map(|value| value.as_str()) - .unwrap_or(""); - let object = captures - .name("object") - .map(|value| value.as_str()) - .unwrap_or(""); - if accumulator - .known_people - .contains_key(&sanitize_entity_name(subject)) - || classify_entity(subject, &accumulator.known_people) == "PERSON" - { - accumulator.add_relation( - subject, - "PERSON", - "owns", - object, - classify_entity(object, &accumulator.known_people), - 0.83, - chunk_index, - order_index, - Map::new(), - ); - accumulator.tags.insert("owner".to_string()); - continue; - } - } - - let upper = sanitize_entity_name(&line); - let decision_subject = accumulator - .primary_subject - .clone() - .or_else(|| accumulator.document_title.clone()) - .unwrap_or_else(|| "DOCUMENT".to_string()); - if upper.contains("JSON-RPC") { - accumulator.add_relation( - &decision_subject, - "PROJECT", - "uses", - "JSON-RPC", - "PRODUCT", - 0.86, - chunk_index, - order_index, - Map::new(), - ); - accumulator - .decisions - .insert(format!("{decision_subject} uses JSON-RPC")); - accumulator.tags.insert("decision".to_string()); - continue; - } - if upper.contains("SHOULD USE NAMESPACE") - || upper.contains("USE NAMESPACE AS THE STORAGE") - || upper.contains("NAMESPACE AS THE MAIN SCOPE KEY") - { - accumulator.add_relation( - &decision_subject, - "PROJECT", - "uses", - "namespace", - "TOPIC", - 0.84, - chunk_index, - order_index, - Map::new(), - ); - accumulator - .decisions - .insert(format!("{decision_subject} uses namespace")); - accumulator.tags.insert("decision".to_string()); - continue; - } - if upper.contains("USER_ID") && (upper.contains("DO NOT NEED") || upper.contains("AVOID")) { - accumulator.add_relation( - &decision_subject, - "PROJECT", - "avoids", - "user_id", - "TOPIC", - 0.82, - chunk_index, - order_index, - Map::new(), - ); - accumulator - .decisions - .insert(format!("{decision_subject} avoids user_id")); - accumulator.tags.insert("decision".to_string()); - } - } - - for unit in build_units(&chunks, config.extraction_mode) { - if let Some(captures) = recipient_regex().captures(&unit.text) { - let giver = captures - .name("giver") - .map(|value| value.as_str()) - .unwrap_or(""); - let object = captures - .name("object") - .map(|value| value.as_str()) - .unwrap_or(""); - let recipient = captures - .name("recipient") - .map(|value| value.as_str()) - .unwrap_or(""); - accumulator.add_relation( - giver, - "PERSON", - "uses", - object, - classify_entity(object, &accumulator.known_people), - config.adjacency_threshold.max(0.62), - unit.chunk_index, - unit.order_index, - Map::new(), - ); - accumulator.add_relation( - recipient, - "PERSON", - "uses", - object, - classify_entity(object, &accumulator.known_people), - (config.adjacency_threshold * 0.9).max(0.55), - unit.chunk_index, - unit.order_index, - Map::new(), - ); - } - - if let Some(captures) = spatial_regex().captures(&unit.text) { - let head = captures - .name("head") - .map(|value| value.as_str()) - .unwrap_or(""); - let direction = captures - .name("direction") - .map(|value| value.as_str()) - .unwrap_or(""); - let tail = captures - .name("tail") - .map(|value| value.as_str()) - .unwrap_or(""); - let inverse = match direction.to_ascii_lowercase().as_str() { - "north" => "south_of", - "south" => "north_of", - "east" => "west_of", - "west" => "east_of", - _ => "", - }; - let predicate = format!("{direction}_of"); - accumulator.add_relation( - head, - "ROOM", - &predicate, - tail, - "ROOM", - config.adjacency_threshold.max(0.70), - unit.chunk_index, - unit.order_index, - Map::new(), - ); - if !inverse.is_empty() { - accumulator.add_relation( - tail, - "ROOM", - inverse, - head, - "ROOM", - config.adjacency_threshold.max(0.70), - unit.chunk_index, - unit.order_index, - Map::new(), - ); - } - } - } - - let aliases = build_alias_map(&accumulator.entities); - let reverse_alias = reverse_aliases(&aliases); - let mut canonical_entities = BTreeMap::::new(); - for entity in accumulator.entities.values() { - let canonical = resolve_alias(&entity.name, &aliases); - let entry = canonical_entities - .entry(canonical.clone()) - .or_insert_with(|| RawEntity { - name: canonical.clone(), - entity_type: entity.entity_type.clone(), - confidence: entity.confidence, - }); - if entity.confidence > entry.confidence { - entry.confidence = entity.confidence; - entry.entity_type = entity.entity_type.clone(); - } - } - - let mut aggregated_relations = BTreeMap::<(String, String, String), RawRelation>::new(); - for relation in accumulator.relations { - let subject = resolve_alias(&relation.subject, &aliases); - let object = resolve_alias(&relation.object, &aliases); - if subject == object { - continue; - } - let key = (subject.clone(), relation.predicate.clone(), object.clone()); - let entry = aggregated_relations - .entry(key) - .or_insert_with(|| RawRelation { - subject, - subject_type: relation.subject_type.clone(), - predicate: relation.predicate.clone(), - object, - object_type: relation.object_type.clone(), - confidence: relation.confidence, - chunk_indexes: relation.chunk_indexes.clone(), - order_index: relation.order_index, - metadata: relation.metadata.clone(), - }); - entry.confidence = entry.confidence.max(relation.confidence); - entry.order_index = entry.order_index.min(relation.order_index); - entry.chunk_indexes.extend(relation.chunk_indexes); - } - - let entities = canonical_entities - .into_values() - .filter(|entity| entity.confidence >= config.entity_threshold) - .map(|entity| ExtractedEntity { - name: entity.name.clone(), - entity_type: entity.entity_type, - aliases: reverse_alias.get(&entity.name).cloned().unwrap_or_default(), - }) - .collect::>(); - - let relations = aggregated_relations - .into_values() - .filter(|relation| relation.confidence >= config.relation_threshold) - .map(|relation| ExtractedRelation { - subject: relation.subject, - subject_type: relation.subject_type, - predicate: relation.predicate, - object: relation.object, - object_type: relation.object_type, - confidence: relation.confidence, - evidence_count: u32::try_from(relation.chunk_indexes.len()).unwrap_or(u32::MAX), - chunk_ids: relation - .chunk_indexes - .iter() - .map(|index| format!("chunk:{index}")) - .collect::>(), - order_index: Some(relation.order_index), - metadata: Value::Object(relation.metadata), - }) - .collect::>(); - - let mut tags = accumulator.tags.into_iter().collect::>(); - tags.sort(); - let metadata = json!({ - "kind": accumulator.doc_kind.or_else(|| { - if !accumulator.preferences.is_empty() || !accumulator.decisions.is_empty() { - Some("profile".to_string()) - } else { - None - } - }), - "primary_subject": accumulator.primary_subject, - "decisions": accumulator.decisions.iter().cloned().collect::>(), - "preferences": accumulator.preferences.iter().cloned().collect::>(), - "extracted_entities": entities.iter().map(|entity| { - json!({ - "name": entity.name, - "entity_type": entity.entity_type, - "aliases": entity.aliases, - }) - }).collect::>(), - }); - - log::debug!( - "[memory:ingestion] parse_document complete title={title:?} \ - entities={} relations={} preferences={} decisions={}", - entities.len(), - relations.len(), - accumulator.preferences.len(), - accumulator.decisions.len(), - ); - - ParsedIngestion { - tags, - metadata, - entities, - relations, - chunk_count: chunks.len(), - preference_count: accumulator.preferences.len(), - decision_count: accumulator.decisions.len(), - } -} - impl UnifiedMemory { pub async fn ingest_document( &self, diff --git a/src/openhuman/memory/ingestion_parse.rs b/src/openhuman/memory/ingestion_parse.rs new file mode 100644 index 000000000..eb7998ec1 --- /dev/null +++ b/src/openhuman/memory/ingestion_parse.rs @@ -0,0 +1,935 @@ +//! Document parsing helpers: chunking, alias resolution, header/metadata enrichment, +//! and the top-level `parse_document` pipeline. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use serde_json::{json, Map, Value}; + +use super::ingestion_regex::{ + action_item_regex, classify_entity, email_header_regex, explicit_owner_regex, + explicit_preference_regex, graph_fact_regex, named_email_regex, recipient_regex, + sanitize_entity_name, sanitize_fact_text, spatial_regex, will_review_regex, +}; +use super::ingestion_types::{ + ExtractedEntity, ExtractedRelation, ExtractionAccumulator, ExtractionMode, ExtractionUnit, + MemoryIngestionConfig, ParsedIngestion, RawEntity, RawRelation, DEFAULT_CHUNK_TOKENS, +}; +use crate::openhuman::memory::store::types::NamespaceDocumentInput; +use crate::openhuman::memory::UnifiedMemory; + +// ── Chunking helpers ────────────────────────────────────────────────────────── + +/// Splits a document into individual sentences based on punctuation and line breaks. +pub(super) fn split_sentences(text: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + for ch in text.chars() { + current.push(ch); + if matches!(ch, '.' | '!' | '?' | '\n') { + let candidate = sanitize_fact_text(¤t); + if !candidate.is_empty() { + out.push(candidate); + } + current.clear(); + } + } + let tail = sanitize_fact_text(¤t); + if !tail.is_empty() { + out.push(tail); + } + let mut merged: Vec = Vec::new(); + for sentence in out { + if sentence.len() < 5 && !merged.is_empty() { + if let Some(last) = merged.last_mut() { + last.push(' '); + last.push_str(&sentence); + } + } else { + merged.push(sentence); + } + } + if merged.is_empty() && !text.trim().is_empty() { + merged.push(sanitize_fact_text(text)); + } + merged +} + +/// Groups chunks into extraction units based on the configured mode. +pub(super) fn build_units(chunks: &[String], mode: ExtractionMode) -> Vec { + let mut units = Vec::new(); + let mut order_index = 0_i64; + for (chunk_index, chunk) in chunks.iter().enumerate() { + match mode { + ExtractionMode::Chunk => { + let text = sanitize_fact_text(chunk); + if text.is_empty() { + continue; + } + units.push(ExtractionUnit { + text, + chunk_index, + order_index, + }); + order_index += 1; + } + ExtractionMode::Sentence => { + for sentence in split_sentences(chunk) { + if sentence.is_empty() { + continue; + } + units.push(ExtractionUnit { + text: sentence, + chunk_index, + order_index, + }); + order_index += 1; + } + } + } + } + units +} + +/// Searches for the chunk index that most likely contains the given excerpt. +pub(super) fn find_chunk_index(chunks: &[String], excerpt: &str, hint: usize) -> usize { + if chunks.is_empty() { + return 0; + } + let needle = UnifiedMemory::normalize_search_text(excerpt); + if needle.is_empty() { + return hint.min(chunks.len().saturating_sub(1)); + } + for (index, chunk) in chunks.iter().enumerate().skip(hint) { + if UnifiedMemory::normalize_search_text(chunk).contains(&needle) { + return index; + } + } + for (index, chunk) in chunks.iter().enumerate().take(hint.min(chunks.len())) { + if UnifiedMemory::normalize_search_text(chunk).contains(&needle) { + return index; + } + } + hint.min(chunks.len().saturating_sub(1)) +} + +// ── Alias resolution ────────────────────────────────────────────────────────── + +pub(super) fn reverse_aliases(aliases: &HashMap) -> BTreeMap> { + let mut reverse = BTreeMap::new(); + for (alias, canonical) in aliases { + if alias == canonical { + continue; + } + reverse + .entry(canonical.clone()) + .or_insert_with(Vec::new) + .push(alias.clone()); + } + for values in reverse.values_mut() { + values.sort(); + values.dedup(); + } + reverse +} + +pub(super) fn build_alias_map(entities: &HashMap) -> HashMap { + let mut by_type = HashMap::>::new(); + for entity in entities.values() { + by_type + .entry(entity.entity_type.clone()) + .or_default() + .push(entity.name.clone()); + } + + let mut aliases = HashMap::new(); + for names in by_type.values_mut() { + names.sort_by_key(|name| std::cmp::Reverse(name.len())); + for short in names.iter() { + for long in names.iter() { + if short == long || long.len() <= short.len() { + continue; + } + if long.starts_with(&format!("{short} ")) || long.ends_with(&format!(" {short}")) { + aliases.entry(short.clone()).or_insert_with(|| long.clone()); + break; + } + } + } + } + aliases +} + +pub(super) fn resolve_alias(name: &str, aliases: &HashMap) -> String { + let mut current = name.to_string(); + let mut seen = BTreeSet::new(); + while let Some(next) = aliases.get(¤t) { + if !seen.insert(current.clone()) { + break; + } + current = next.clone(); + } + current +} + +// ── Header / metadata helpers ───────────────────────────────────────────────── + +pub(super) fn extract_people_from_header( + value: &str, + accumulator: &mut ExtractionAccumulator, +) -> Vec { + let mut people = Vec::new(); + for captures in named_email_regex().captures_iter(value) { + let name = sanitize_fact_text( + captures + .name("name") + .map(|value| value.as_str()) + .unwrap_or(""), + ); + if name.is_empty() { + continue; + } + let canonical = sanitize_entity_name(&name); + let _ = accumulator.add_entity(&canonical, "PERSON", 0.95); + accumulator.remember_person_aliases(&canonical); + people.push(canonical); + } + people +} + +pub(super) fn detect_primary_subject(text: &str) -> Option { + if text.contains("OpenHuman") { + return Some("OPENHUMAN".to_string()); + } + None +} + +pub(super) fn enrich_document_metadata( + input: &NamespaceDocumentInput, + parsed: &ParsedIngestion, + config: &MemoryIngestionConfig, +) -> (NamespaceDocumentInput, Vec) { + let mut metadata = match input.metadata.clone() { + Value::Object(map) => map, + _ => Map::new(), + }; + for (key, value) in parsed.metadata.as_object().cloned().unwrap_or_default() { + metadata.insert(key, value); + } + metadata.insert( + "ingestion".to_string(), + json!({ + "backend": "openhuman_rust_heuristic", + "model_name": config.model_name, + "extraction_mode": config.extraction_mode.as_str(), + "entity_count": parsed.entities.len(), + "relation_count": parsed.relations.len(), + "preference_count": parsed.preference_count, + "decision_count": parsed.decision_count, + "chunk_count": parsed.chunk_count, + }), + ); + if parsed.preference_count > 0 || parsed.decision_count > 0 { + metadata.insert("kind".to_string(), json!("profile")); + } + + let mut tags = input.tags.iter().cloned().collect::>(); + tags.extend(parsed.tags.iter().cloned()); + let tags = tags.into_iter().collect::>(); + + ( + NamespaceDocumentInput { + namespace: input.namespace.clone(), + key: input.key.clone(), + title: input.title.clone(), + content: input.content.clone(), + source_type: input.source_type.clone(), + priority: input.priority.clone(), + tags: tags.clone(), + metadata: Value::Object(metadata), + category: input.category.clone(), + session_id: input.session_id.clone(), + document_id: input.document_id.clone(), + }, + tags, + ) +} + +// ── Top-level document parser ───────────────────────────────────────────────── + +pub(super) async fn parse_document( + content: &str, + title: &str, + config: &MemoryIngestionConfig, +) -> ParsedIngestion { + let chunks = UnifiedMemory::chunk_document_content(content, DEFAULT_CHUNK_TOKENS); + log::info!( + "[memory:ingestion] parse_document title={title:?} model={} \ + content_len={} chunk_count={} — heuristic extraction active", + config.model_name, + content.len(), + chunks.len(), + ); + let mut accumulator = ExtractionAccumulator { + document_title: Some(sanitize_entity_name(title)), + primary_subject: detect_primary_subject(title), + ..ExtractionAccumulator::default() + }; + + let mut chunk_hint = 0_usize; + for raw_line in content.lines() { + let line = sanitize_fact_text(raw_line); + if line.is_empty() { + continue; + } + + let chunk_index = find_chunk_index(&chunks, &line, chunk_hint); + chunk_hint = chunk_index; + let order_index = i64::try_from(chunk_index).unwrap_or(i64::MAX); + + if raw_line.trim_start().starts_with('#') { + let heading = sanitize_entity_name(raw_line.trim_start_matches('#')); + if !heading.is_empty() { + if accumulator.document_title.is_none() { + accumulator.document_title = Some(heading.clone()); + } + accumulator.current_subject = Some(heading); + } + continue; + } + + if let Some(captures) = email_header_regex().captures(&line) { + let header_name = captures + .get(1) + .map(|value| value.as_str()) + .unwrap_or_default() + .to_ascii_uppercase(); + let value = captures + .name("value") + .map(|value| value.as_str()) + .unwrap_or(""); + let people = extract_people_from_header(value, &mut accumulator); + if header_name == "FROM" { + accumulator.current_sender = people.first().cloned(); + } else if header_name == "TO" || header_name == "CC" { + if let Some(sender) = accumulator.current_sender.clone() { + for recipient in &people { + accumulator.add_relation( + &sender, + "PERSON", + "communicates_with", + recipient, + "PERSON", + 0.82, + chunk_index, + order_index, + Map::new(), + ); + } + } + } + continue; + } + + if let Some(subject) = line.strip_prefix("Subject:") { + let subject_text = sanitize_fact_text(subject); + if let Some(primary_subject) = detect_primary_subject(&subject_text) { + accumulator.primary_subject = Some(primary_subject); + } + continue; + } + + if let Some(date_text) = line.strip_prefix("Date:") { + let date_text = sanitize_fact_text(date_text); + if let Some(sender) = accumulator.current_sender.clone() { + accumulator.add_relation( + &sender, + "PERSON", + "has_deadline", + &date_text, + "DATE", + 0.75, + chunk_index, + order_index, + Map::new(), + ); + } + continue; + } + + if let Some(value) = line.strip_prefix("Project name:") { + let project = sanitize_entity_name(value); + if !project.is_empty() { + accumulator.primary_subject = Some(project.clone()); + let _ = accumulator.add_entity(&project, "PROJECT", 0.96); + } + continue; + } + + if let Some(value) = line.strip_prefix("Subproject:") { + let subproject = sanitize_entity_name(value); + if !subproject.is_empty() { + let _ = accumulator.add_entity(&subproject, "PROJECT", 0.92); + } + continue; + } + + if let Some(value) = line.strip_prefix("Owner:") { + let owner = sanitize_entity_name(value); + let owned = accumulator + .current_subject + .clone() + .or_else(|| accumulator.primary_subject.clone()) + .or_else(|| accumulator.document_title.clone()) + .unwrap_or_else(|| "DOCUMENT".to_string()); + accumulator.add_relation( + &owner, + "PERSON", + "owns", + &owned, + "WORK_ITEM", + 0.94, + chunk_index, + order_index, + Map::new(), + ); + continue; + } + + if let Some(value) = line.strip_prefix("Name:") { + let name = sanitize_entity_name(value); + if !name.is_empty() { + accumulator.current_subject = Some(name.clone()); + let _ = accumulator.add_entity(&name, "WORK_ITEM", 0.93); + } + continue; + } + + if let Some(value) = line.strip_prefix("Due date:") { + let due_date = sanitize_fact_text(value); + let subject = accumulator + .current_subject + .clone() + .or_else(|| accumulator.primary_subject.clone()) + .unwrap_or_else(|| "DOCUMENT".to_string()); + accumulator.add_relation( + &subject, + "WORK_ITEM", + "has_deadline", + &due_date, + "DATE", + 0.92, + chunk_index, + order_index, + Map::new(), + ); + accumulator.tags.insert("deadline".to_string()); + continue; + } + + if let Some(value) = line.strip_prefix("Target milestone:") { + let due_date = sanitize_fact_text(value); + let subject = accumulator + .primary_subject + .clone() + .or_else(|| accumulator.document_title.clone()) + .unwrap_or_else(|| "DOCUMENT".to_string()); + accumulator.add_relation( + &subject, + "PROJECT", + "has_deadline", + &due_date, + "DATE", + 0.92, + chunk_index, + order_index, + Map::new(), + ); + accumulator.tags.insert("deadline".to_string()); + continue; + } + + if let Some(value) = line.strip_prefix("Preferred embedding model for local experiments:") { + let model = sanitize_fact_text(value); + let subject = accumulator + .primary_subject + .clone() + .or_else(|| accumulator.document_title.clone()) + .unwrap_or_else(|| "DOCUMENT".to_string()); + accumulator.add_relation( + &subject, + "PROJECT", + "uses", + &model, + "TOOL", + 0.88, + chunk_index, + order_index, + Map::new(), + ); + accumulator + .decisions + .insert(format!("{subject} uses {model}")); + accumulator.tags.insert("decision".to_string()); + continue; + } + + if let Some(value) = line.strip_prefix("Preferred extraction mode to try first:") { + let mode = sanitize_fact_text(value); + let subject = accumulator + .primary_subject + .clone() + .or_else(|| accumulator.document_title.clone()) + .unwrap_or_else(|| "DOCUMENT".to_string()); + accumulator.add_relation( + &subject, + "PROJECT", + "uses", + &mode, + "MODE", + 0.88, + chunk_index, + order_index, + Map::new(), + ); + accumulator + .decisions + .insert(format!("{subject} uses {mode}")); + accumulator.tags.insert("decision".to_string()); + continue; + } + + if let Some(captures) = graph_fact_regex().captures(&line) { + let subject = captures + .name("subject") + .map(|value| value.as_str()) + .unwrap_or(""); + let predicate = captures + .name("predicate") + .map(|value| value.as_str()) + .unwrap_or(""); + let object = captures + .name("object") + .map(|value| value.as_str()) + .unwrap_or(""); + let subject_type = classify_entity(subject, &accumulator.known_people); + let object_type = classify_entity(object, &accumulator.known_people); + accumulator.add_relation( + subject, + subject_type, + predicate, + object, + object_type, + 0.87, + chunk_index, + order_index, + Map::new(), + ); + if UnifiedMemory::normalize_graph_predicate(predicate) == "PREFERS" { + accumulator.preferences.insert(format!( + "{} prefers {}", + sanitize_entity_name(subject), + sanitize_fact_text(object) + )); + accumulator.tags.insert("preference".to_string()); + accumulator.doc_kind = Some("profile".to_string()); + } + continue; + } + + if let Some(captures) = explicit_owner_regex().captures(&line) { + let subject = captures + .name("subject") + .map(|value| value.as_str()) + .unwrap_or(""); + let object = captures + .name("object") + .map(|value| value.as_str()) + .unwrap_or(""); + accumulator.add_relation( + subject, + "PERSON", + "owns", + object, + classify_entity(object, &accumulator.known_people), + 0.94, + chunk_index, + order_index, + Map::new(), + ); + accumulator.tags.insert("owner".to_string()); + continue; + } + + if let Some(captures) = will_review_regex().captures(&line) { + let subject = captures + .name("subject") + .map(|value| value.as_str()) + .unwrap_or(""); + let object = captures + .name("object") + .map(|value| value.as_str()) + .unwrap_or(""); + accumulator.add_relation( + subject, + "PERSON", + "reviews", + object, + classify_entity(object, &accumulator.known_people), + 0.80, + chunk_index, + order_index, + Map::new(), + ); + accumulator.tags.insert("owner".to_string()); + continue; + } + + if let Some(captures) = explicit_preference_regex().captures(&line) { + let subject = captures + .name("subject") + .map(|value| value.as_str()) + .unwrap_or(""); + let object = captures + .name("object") + .map(|value| value.as_str()) + .unwrap_or(""); + accumulator.add_relation( + subject, + "PERSON", + "prefers", + object, + classify_entity(object, &accumulator.known_people), + 0.90, + chunk_index, + order_index, + Map::new(), + ); + accumulator.preferences.insert(format!( + "{} prefers {}", + sanitize_entity_name(subject), + sanitize_fact_text(object) + )); + accumulator.tags.insert("preference".to_string()); + accumulator.doc_kind = Some("profile".to_string()); + continue; + } + + if let Some(value) = line.strip_prefix("I prefer ") { + if let Some(subject) = accumulator.current_sender.clone() { + let preference = sanitize_fact_text(value); + accumulator.add_relation( + &subject, + "PERSON", + "prefers", + &preference, + classify_entity(&preference, &accumulator.known_people), + 0.92, + chunk_index, + order_index, + Map::new(), + ); + accumulator + .preferences + .insert(format!("{subject} prefers {preference}")); + accumulator.tags.insert("preference".to_string()); + accumulator.doc_kind = Some("profile".to_string()); + continue; + } + } + + if let Some(captures) = action_item_regex().captures(&line) { + let subject = captures + .name("subject") + .map(|value| value.as_str()) + .unwrap_or(""); + let object = captures + .name("object") + .map(|value| value.as_str()) + .unwrap_or(""); + if accumulator + .known_people + .contains_key(&sanitize_entity_name(subject)) + || classify_entity(subject, &accumulator.known_people) == "PERSON" + { + accumulator.add_relation( + subject, + "PERSON", + "owns", + object, + classify_entity(object, &accumulator.known_people), + 0.83, + chunk_index, + order_index, + Map::new(), + ); + accumulator.tags.insert("owner".to_string()); + continue; + } + } + + let upper = sanitize_entity_name(&line); + let decision_subject = accumulator + .primary_subject + .clone() + .or_else(|| accumulator.document_title.clone()) + .unwrap_or_else(|| "DOCUMENT".to_string()); + if upper.contains("JSON-RPC") { + accumulator.add_relation( + &decision_subject, + "PROJECT", + "uses", + "JSON-RPC", + "PRODUCT", + 0.86, + chunk_index, + order_index, + Map::new(), + ); + accumulator + .decisions + .insert(format!("{decision_subject} uses JSON-RPC")); + accumulator.tags.insert("decision".to_string()); + continue; + } + if upper.contains("SHOULD USE NAMESPACE") + || upper.contains("USE NAMESPACE AS THE STORAGE") + || upper.contains("NAMESPACE AS THE MAIN SCOPE KEY") + { + accumulator.add_relation( + &decision_subject, + "PROJECT", + "uses", + "namespace", + "TOPIC", + 0.84, + chunk_index, + order_index, + Map::new(), + ); + accumulator + .decisions + .insert(format!("{decision_subject} uses namespace")); + accumulator.tags.insert("decision".to_string()); + continue; + } + if upper.contains("USER_ID") && (upper.contains("DO NOT NEED") || upper.contains("AVOID")) { + accumulator.add_relation( + &decision_subject, + "PROJECT", + "avoids", + "user_id", + "TOPIC", + 0.82, + chunk_index, + order_index, + Map::new(), + ); + accumulator + .decisions + .insert(format!("{decision_subject} avoids user_id")); + accumulator.tags.insert("decision".to_string()); + } + } + + for unit in build_units(&chunks, config.extraction_mode) { + if let Some(captures) = recipient_regex().captures(&unit.text) { + let giver = captures + .name("giver") + .map(|value| value.as_str()) + .unwrap_or(""); + let object = captures + .name("object") + .map(|value| value.as_str()) + .unwrap_or(""); + let recipient = captures + .name("recipient") + .map(|value| value.as_str()) + .unwrap_or(""); + accumulator.add_relation( + giver, + "PERSON", + "uses", + object, + classify_entity(object, &accumulator.known_people), + config.adjacency_threshold.max(0.62), + unit.chunk_index, + unit.order_index, + Map::new(), + ); + accumulator.add_relation( + recipient, + "PERSON", + "uses", + object, + classify_entity(object, &accumulator.known_people), + (config.adjacency_threshold * 0.9).max(0.55), + unit.chunk_index, + unit.order_index, + Map::new(), + ); + } + + if let Some(captures) = spatial_regex().captures(&unit.text) { + let head = captures + .name("head") + .map(|value| value.as_str()) + .unwrap_or(""); + let direction = captures + .name("direction") + .map(|value| value.as_str()) + .unwrap_or(""); + let tail = captures + .name("tail") + .map(|value| value.as_str()) + .unwrap_or(""); + let inverse = match direction.to_ascii_lowercase().as_str() { + "north" => "south_of", + "south" => "north_of", + "east" => "west_of", + "west" => "east_of", + _ => "", + }; + let predicate = format!("{direction}_of"); + accumulator.add_relation( + head, + "ROOM", + &predicate, + tail, + "ROOM", + config.adjacency_threshold.max(0.70), + unit.chunk_index, + unit.order_index, + Map::new(), + ); + if !inverse.is_empty() { + accumulator.add_relation( + tail, + "ROOM", + inverse, + head, + "ROOM", + config.adjacency_threshold.max(0.70), + unit.chunk_index, + unit.order_index, + Map::new(), + ); + } + } + } + + let aliases = build_alias_map(&accumulator.entities); + let reverse_alias = reverse_aliases(&aliases); + let mut canonical_entities = BTreeMap::::new(); + for entity in accumulator.entities.values() { + let canonical = resolve_alias(&entity.name, &aliases); + let entry = canonical_entities + .entry(canonical.clone()) + .or_insert_with(|| RawEntity { + name: canonical.clone(), + entity_type: entity.entity_type.clone(), + confidence: entity.confidence, + }); + if entity.confidence > entry.confidence { + entry.confidence = entity.confidence; + entry.entity_type = entity.entity_type.clone(); + } + } + + let mut aggregated_relations = BTreeMap::<(String, String, String), _>::new(); + for relation in accumulator.relations { + let subject = resolve_alias(&relation.subject, &aliases); + let object = resolve_alias(&relation.object, &aliases); + if subject == object { + continue; + } + let key = (subject.clone(), relation.predicate.clone(), object.clone()); + let entry = aggregated_relations + .entry(key) + .or_insert_with(|| RawRelation { + subject, + subject_type: relation.subject_type.clone(), + predicate: relation.predicate.clone(), + object, + object_type: relation.object_type.clone(), + confidence: relation.confidence, + chunk_indexes: relation.chunk_indexes.clone(), + order_index: relation.order_index, + metadata: relation.metadata.clone(), + }); + entry.confidence = entry.confidence.max(relation.confidence); + entry.order_index = entry.order_index.min(relation.order_index); + entry.chunk_indexes.extend(relation.chunk_indexes); + } + + let entities = canonical_entities + .into_values() + .filter(|entity| entity.confidence >= config.entity_threshold) + .map(|entity| ExtractedEntity { + name: entity.name.clone(), + entity_type: entity.entity_type, + aliases: reverse_alias.get(&entity.name).cloned().unwrap_or_default(), + }) + .collect::>(); + + let relations = aggregated_relations + .into_values() + .filter(|relation| relation.confidence >= config.relation_threshold) + .map(|relation| ExtractedRelation { + subject: relation.subject, + subject_type: relation.subject_type, + predicate: relation.predicate, + object: relation.object, + object_type: relation.object_type, + confidence: relation.confidence, + evidence_count: u32::try_from(relation.chunk_indexes.len()).unwrap_or(u32::MAX), + chunk_ids: relation + .chunk_indexes + .iter() + .map(|index| format!("chunk:{index}")) + .collect::>(), + order_index: Some(relation.order_index), + metadata: Value::Object(relation.metadata), + }) + .collect::>(); + + let mut tags = accumulator.tags.into_iter().collect::>(); + tags.sort(); + let metadata = json!({ + "kind": accumulator.doc_kind.or_else(|| { + if !accumulator.preferences.is_empty() || !accumulator.decisions.is_empty() { + Some("profile".to_string()) + } else { + None + } + }), + "primary_subject": accumulator.primary_subject, + "decisions": accumulator.decisions.iter().cloned().collect::>(), + "preferences": accumulator.preferences.iter().cloned().collect::>(), + "extracted_entities": entities.iter().map(|entity| { + json!({ + "name": entity.name, + "entity_type": entity.entity_type, + "aliases": entity.aliases, + }) + }).collect::>(), + }); + + log::debug!( + "[memory:ingestion] parse_document complete title={title:?} \ + entities={} relations={} preferences={} decisions={}", + entities.len(), + relations.len(), + accumulator.preferences.len(), + accumulator.decisions.len(), + ); + + ParsedIngestion { + tags, + metadata, + entities, + relations, + chunk_count: chunks.len(), + preference_count: accumulator.preferences.len(), + decision_count: accumulator.decisions.len(), + } +} diff --git a/src/openhuman/memory/ingestion_regex.rs b/src/openhuman/memory/ingestion_regex.rs new file mode 100644 index 000000000..ead2ae59d --- /dev/null +++ b/src/openhuman/memory/ingestion_regex.rs @@ -0,0 +1,189 @@ +//! Lazily-initialised regex patterns and text-sanitization helpers for document ingestion. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use regex::Regex; + +use crate::openhuman::memory::UnifiedMemory; + +/// Regex for identifying standard email headers (From, To, Cc). +pub(super) fn email_header_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX + .get_or_init(|| Regex::new(r"^(From|To|Cc):\s*(?P.+)$").expect("email header regex")) +} + +/// Regex for identifying named email addresses (e.g., "John Doe "). +pub(super) fn named_email_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?P[^,<]+?)\s*<(?P[^>]+)>").expect("named email regex") + }) +} + +/// Regex for identifying explicit graph facts (e.g., "Alice works_on Project-X"). +pub(super) fn graph_fact_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r"^(?P[A-Za-z0-9][A-Za-z0-9 ._\-/]+?)\s+(?Pworks_on|depends_on|uses|evaluates|owns|prefers)\s+(?P.+)$", + ) + .expect("graph fact regex") + }) +} + +/// Regex for identifying ownership patterns (e.g., "Bob owns the repository"). +pub(super) fn explicit_owner_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"^(?P[A-Za-z][A-Za-z ._-]+?) owns (?P.+)$") + .expect("explicit owner regex") + }) +} + +/// Regex for identifying preference patterns (e.g., "Carol prefers light mode"). +pub(super) fn explicit_preference_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"^(?P[A-Za-z][A-Za-z ._-]+?) prefers (?P.+)$") + .expect("explicit preference regex") + }) +} + +/// Regex for identifying action items or assignments (e.g., "Dave: finish the API"). +pub(super) fn action_item_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"^(?P[A-Za-z][A-Za-z ._-]+?):\s*(?P.+)$") + .expect("action item regex") + }) +} + +/// Regex for identifying review assignments. +pub(super) fn will_review_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"^(?P[A-Za-z][A-Za-z ._-]+?) will review (?P.+)$") + .expect("will review regex") + }) +} + +/// Regex for identifying complex giving/receiving interactions. +pub(super) fn recipient_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r"(?i)(?P[A-Z][A-Za-z]+(?: [A-Z][A-Za-z]+)?)\s+(gave|sent|handed|passed)\s+(?P.+?)\s+to\s+(?P[A-Z][A-Za-z]+(?: [A-Z][A-Za-z]+)?)", + ) + .expect("recipient regex") + }) +} + +/// Regex for identifying spatial relationships (e.g., "Kitchen is north of the Garden"). +pub(super) fn spatial_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r"(?i)(?P[A-Za-z][A-Za-z0-9 _-]+?)\s+is\s+(?Pnorth|south|east|west)\s+of\s+(?P[A-Za-z][A-Za-z0-9 _-]+)", + ) + .expect("spatial regex") + }) +} + +/// Regex for identifying dates in "Month DD, YYYY" format. +pub(super) fn month_date_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?i)\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\s+\d{1,2},\s+\d{4}\b") + .expect("month date regex") + }) +} + +/// Regex for identifying ISO-8601 dates (YYYY-MM-DD). +pub(super) fn iso_date_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"\b\d{4}-\d{2}-\d{2}\b").expect("iso date regex")) +} + +/// Regex for identifying potential person names (Title Case). +pub(super) fn person_name_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX + .get_or_init(|| Regex::new(r"\b[A-Z][a-z]+(?: [A-Z][a-z]+)+\b").expect("person name regex")) +} + +/// Normalizes an entity name by trimming punctuation, collapsing whitespace, and converting to uppercase. +pub(super) fn sanitize_entity_name(name: &str) -> String { + let trimmed = name.trim().trim_matches(|ch: char| { + matches!(ch, '-' | ':' | ';' | ',' | '.' | '"' | '\'' | '(' | ')') + }); + if trimmed.is_empty() { + return String::new(); + } + UnifiedMemory::collapse_whitespace(trimmed).to_uppercase() +} + +/// Normalizes text content by trimming and collapsing whitespace. +pub(super) fn sanitize_fact_text(text: &str) -> String { + let trimmed = text + .trim() + .trim_start_matches('-') + .trim() + .trim_matches(|ch: char| matches!(ch, ':' | ';' | ',' | '.')); + UnifiedMemory::collapse_whitespace(trimmed) +} + +/// Heuristically classifies an entity based on its name and known person map. +pub(super) fn classify_entity(name: &str, known_people: &HashMap) -> &'static str { + let upper = sanitize_entity_name(name); + if upper.is_empty() { + return "TOPIC"; + } + if month_date_regex().is_match(name) || iso_date_regex().is_match(name) { + return "DATE"; + } + if upper.contains('@') { + return "ORGANIZATION"; + } + if known_people.contains_key(&upper) || person_name_regex().is_match(name) { + return "PERSON"; + } + if matches!( + upper.as_str(), + "OPENHUMAN" | "JSON-RPC" | "JSON-RPC 2.0" | "NEOCORTEX_V2" | "NEOCORTEX V2" + ) { + return "PRODUCT"; + } + if upper.contains("MODEL") { + return "TOOL"; + } + if upper.contains("MODE") { + return "MODE"; + } + if upper.contains("MILESTONE") + || upper.contains("ROADMAP") + || upper.contains("CONTRACT") + || upper.contains("API") + || upper.contains("MEMORY") + || upper.contains("FIXTURE") + || upper.contains("THREAD") + || upper.contains("WORK") + { + return "WORK_ITEM"; + } + if upper.contains("OFFICE") + || upper.contains("ROOM") + || upper.contains("GARDEN") + || upper.contains("KITCHEN") + { + return "ROOM"; + } + if upper.contains("TINYHUMANS") || upper.ends_with("CORE") { + return "ORGANIZATION"; + } + if (upper.contains('-') || upper.contains('_')) && !upper.contains(' ') { + return "PROJECT"; + } + "TOPIC" +} diff --git a/src/openhuman/memory/ingestion_rules.rs b/src/openhuman/memory/ingestion_rules.rs new file mode 100644 index 000000000..aa16412fc --- /dev/null +++ b/src/openhuman/memory/ingestion_rules.rs @@ -0,0 +1,222 @@ +//! Semantic validation rules for knowledge-graph relations and `ExtractionAccumulator` impl. + +use std::collections::BTreeSet; + +use serde_json::{Map, Value}; + +use super::ingestion_regex::sanitize_entity_name; +use super::ingestion_types::{ExtractionAccumulator, RawEntity, RawRelation}; +use crate::openhuman::memory::UnifiedMemory; + +/// A validation rule for semantic relationships. +#[derive(Debug)] +pub(super) struct RelationRule { + /// Canonical predicate name (uppercase snake_case). + pub(super) canonical: &'static str, + /// Allowed classifications for the subject. + pub(super) allowed_head: &'static [&'static str], + /// Allowed classifications for the object. + pub(super) allowed_tail: &'static [&'static str], +} + +const PERSON_TYPES: &[&str] = &["PERSON"]; +const ORG_TYPES: &[&str] = &[ + "ORGANIZATION", + "PROJECT", + "PRODUCT", + "TOOL", + "TOPIC", + "WORK_ITEM", +]; +const PLACE_TYPES: &[&str] = &["PLACE", "LOCATION", "ROOM"]; +const DATE_TYPES: &[&str] = &["DATE"]; + +/// Returns the semantic validation rule for a given predicate name. +pub(super) fn relation_rule(predicate: &str) -> Option { + let normalized = UnifiedMemory::normalize_graph_predicate(predicate); + let rule = match normalized.as_str() { + "OWNS" | "WORKS_ON" | "RESPONSIBLE_FOR" | "REVIEWS" => RelationRule { + canonical: "OWNS", + allowed_head: PERSON_TYPES, + allowed_tail: ORG_TYPES, + }, + "USES" | "KEEPS" | "ADOPTS" => RelationRule { + canonical: "USES", + allowed_head: ORG_TYPES, + allowed_tail: ORG_TYPES, + }, + "WORKS_FOR" => RelationRule { + canonical: "WORKS_FOR", + allowed_head: PERSON_TYPES, + allowed_tail: &["ORGANIZATION", "PROJECT", "PRODUCT"], + }, + "DEPENDS_ON" => RelationRule { + canonical: "DEPENDS_ON", + allowed_head: ORG_TYPES, + allowed_tail: ORG_TYPES, + }, + "PREFERS" => RelationRule { + canonical: "PREFERS", + allowed_head: PERSON_TYPES, + allowed_tail: &["TOPIC", "WORK_ITEM", "MODE", "PRODUCT", "TOOL"], + }, + "HAS_DEADLINE" | "DUE_ON" => RelationRule { + canonical: "HAS_DEADLINE", + allowed_head: ORG_TYPES, + allowed_tail: DATE_TYPES, + }, + "COMMUNICATES_WITH" => RelationRule { + canonical: "COMMUNICATES_WITH", + allowed_head: PERSON_TYPES, + allowed_tail: PERSON_TYPES, + }, + "INVESTIGATES" | "EVALUATES" => RelationRule { + canonical: "INVESTIGATES", + allowed_head: PERSON_TYPES, + allowed_tail: ORG_TYPES, + }, + "NORTH_OF" => RelationRule { + canonical: "NORTH_OF", + allowed_head: PLACE_TYPES, + allowed_tail: PLACE_TYPES, + }, + "SOUTH_OF" => RelationRule { + canonical: "SOUTH_OF", + allowed_head: PLACE_TYPES, + allowed_tail: PLACE_TYPES, + }, + "EAST_OF" => RelationRule { + canonical: "EAST_OF", + allowed_head: PLACE_TYPES, + allowed_tail: PLACE_TYPES, + }, + "WEST_OF" => RelationRule { + canonical: "WEST_OF", + allowed_head: PLACE_TYPES, + allowed_tail: PLACE_TYPES, + }, + "AVOIDS" => RelationRule { + canonical: "AVOIDS", + allowed_head: ORG_TYPES, + allowed_tail: ORG_TYPES, + }, + _ => return None, + }; + Some(rule) +} + +/// Helper to check if a classification is allowed by a rule. +pub(super) fn type_allowed(actual: &str, allowed: &[&str]) -> bool { + allowed.is_empty() || allowed.iter().any(|candidate| candidate == &actual) +} + +/// Resolves a person's name using the known alias map. +pub(super) fn resolve_person_alias( + name: &str, + known_people: &std::collections::HashMap, +) -> String { + let upper = name.to_uppercase(); + known_people.get(&upper).cloned().unwrap_or(upper) +} + +impl ExtractionAccumulator { + /// Ingests a full name and its components (e.g., first name) into the alias map. + pub(super) fn remember_person_aliases(&mut self, canonical_name: &str) { + let parts = canonical_name.split_whitespace().collect::>(); + if let Some(first_name) = parts.first() { + self.known_people + .entry(first_name.to_uppercase()) + .or_insert_with(|| canonical_name.to_string()); + } + } + + /// Records a new entity, updating confidence if already known. + pub(super) fn add_entity( + &mut self, + name: &str, + entity_type: &str, + confidence: f32, + ) -> Option { + let cleaned = sanitize_entity_name(name); + if cleaned.is_empty() { + return None; + } + let resolved_name = if entity_type == "PERSON" { + resolve_person_alias(&cleaned, &self.known_people) + } else { + cleaned.clone() + }; + let entry = self + .entities + .entry(resolved_name.clone()) + .or_insert_with(|| RawEntity { + name: resolved_name.clone(), + entity_type: entity_type.to_string(), + confidence, + }); + if confidence > entry.confidence { + entry.confidence = confidence; + } + if entity_type == "PERSON" { + self.remember_person_aliases(&resolved_name); + } + Some(resolved_name) + } + + /// Records a new relationship, applying semantic validation rules. + #[allow(clippy::too_many_arguments)] + pub(super) fn add_relation( + &mut self, + subject: &str, + subject_type: &str, + predicate: &str, + object: &str, + object_type: &str, + confidence: f32, + chunk_index: usize, + order_index: i64, + metadata: Map, + ) { + let Some(rule) = relation_rule(predicate) else { + return; + }; + let Some(subject_name) = self.add_entity(subject, subject_type, confidence) else { + return; + }; + let Some(object_name) = self.add_entity(object, object_type, confidence) else { + return; + }; + if subject_name == object_name { + return; + } + let actual_subject_type = self + .entities + .get(&subject_name) + .map(|value| value.entity_type.as_str()) + .unwrap_or(subject_type); + let actual_object_type = self + .entities + .get(&object_name) + .map(|value| value.entity_type.as_str()) + .unwrap_or(object_type); + if !type_allowed(actual_subject_type, rule.allowed_head) + || !type_allowed(actual_object_type, rule.allowed_tail) + { + return; + } + + let mut chunk_indexes = BTreeSet::new(); + chunk_indexes.insert(chunk_index); + self.relations.push(RawRelation { + subject: subject_name, + subject_type: actual_subject_type.to_string(), + predicate: rule.canonical.to_string(), + object: object_name, + object_type: actual_object_type.to_string(), + confidence, + chunk_indexes, + order_index, + metadata, + }); + } +} diff --git a/src/openhuman/memory/ingestion_types.rs b/src/openhuman/memory/ingestion_types.rs new file mode 100644 index 000000000..1c1a79794 --- /dev/null +++ b/src/openhuman/memory/ingestion_types.rs @@ -0,0 +1,245 @@ +//! Public and private types for the memory ingestion pipeline. + +use std::collections::{BTreeSet, HashMap}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::openhuman::memory::store::types::NamespaceDocumentInput; + +/// Default extraction backend label reported in ingestion metadata. +pub const DEFAULT_MEMORY_EXTRACTION_MODEL: &str = "heuristic-only"; +/// Default number of tokens per text chunk during ingestion. +pub(super) const DEFAULT_CHUNK_TOKENS: usize = 225; + +/// Granularity of extraction for heuristic parsing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum ExtractionMode { + /// Extract from each individual sentence (higher precision). + #[default] + Sentence, + /// Extract from the entire chunk at once (faster, better for context). + Chunk, +} + +impl ExtractionMode { + /// Returns the string representation of the extraction mode. + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Sentence => "sentence", + Self::Chunk => "chunk", + } + } +} + +/// Configuration for the memory ingestion process. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryIngestionConfig { + /// Extraction backend label recorded in metadata/results. + pub model_name: String, + /// The granularity of heuristic extraction. + #[serde(default)] + pub extraction_mode: ExtractionMode, + /// Minimum confidence threshold for entity extraction (0.0 to 1.0). + #[serde(default = "default_entity_threshold")] + pub entity_threshold: f32, + /// Minimum confidence threshold for relation extraction (0.0 to 1.0). + #[serde(default = "default_relation_threshold")] + pub relation_threshold: f32, + /// Threshold for adjacency-based heuristics. + #[serde(default = "default_adjacency_threshold")] + pub adjacency_threshold: f32, + /// Reserved batch-size knob kept for config compatibility. + #[serde(default = "default_batch_size")] + pub batch_size: usize, +} + +fn default_entity_threshold() -> f32 { + 0.45 +} + +fn default_relation_threshold() -> f32 { + 0.30 +} + +fn default_adjacency_threshold() -> f32 { + 0.50 +} + +fn default_batch_size() -> usize { + 16 +} + +impl Default for MemoryIngestionConfig { + fn default() -> Self { + Self { + model_name: DEFAULT_MEMORY_EXTRACTION_MODEL.to_string(), + extraction_mode: ExtractionMode::Sentence, + entity_threshold: default_entity_threshold(), + relation_threshold: default_relation_threshold(), + adjacency_threshold: default_adjacency_threshold(), + batch_size: default_batch_size(), + } + } +} + +/// A request to ingest a single document. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryIngestionRequest { + /// The document input to process. + pub document: NamespaceDocumentInput, + /// Ingestion configuration. + #[serde(default)] + pub config: MemoryIngestionConfig, +} + +/// An entity identified during the ingestion process. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtractedEntity { + /// Normalized name of the entity (all-caps). + pub name: String, + /// Classification (e.g., PERSON, ORGANIZATION). + pub entity_type: String, + /// Known aliases for this entity. + #[serde(default)] + pub aliases: Vec, +} + +/// A relation identified during the ingestion process. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtractedRelation { + /// Name of the subject entity. + pub subject: String, + /// Classification of the subject. + pub subject_type: String, + /// Relationship type (e.g., OWNS, WORKS_ON). + pub predicate: String, + /// Name of the object entity. + pub object: String, + /// Classification of the object. + pub object_type: String, + /// Extraction confidence (0.0 to 1.0). + pub confidence: f32, + /// Number of distinct occurrences of this relation. + pub evidence_count: u32, + /// IDs of the chunks where this relation was found. + pub chunk_ids: Vec, + /// Sequential order index for reconstruction. + pub order_index: Option, + /// Additional metadata about the extraction. + pub metadata: Value, +} + +/// The comprehensive result of an ingestion operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryIngestionResult { + /// ID of the document that was ingested. + pub document_id: String, + /// Namespace containing the document. + pub namespace: String, + /// Extraction backend label recorded for the ingestion run. + pub model_name: String, + /// Mode used for extraction. + pub extraction_mode: String, + /// Total number of chunks processed. + pub chunk_count: usize, + /// Total number of distinct entities found. + pub entity_count: usize, + /// Total number of distinct relations found. + pub relation_count: usize, + /// Number of identified user preferences. + pub preference_count: usize, + /// Number of identified decisions. + pub decision_count: usize, + /// Auto-generated tags for the document. + #[serde(default)] + pub tags: Vec, + /// Complete list of identified entities. + #[serde(default)] + pub entities: Vec, + /// Complete list of identified relations. + #[serde(default)] + pub relations: Vec, +} + +/// Intermediate representation of an entity before normalization and alias resolution. +#[derive(Debug, Clone)] +pub(super) struct RawEntity { + pub(super) name: String, + pub(super) entity_type: String, + pub(super) confidence: f32, +} + +/// Intermediate representation of a relationship before aggregation. +#[derive(Debug, Clone)] +pub(super) struct RawRelation { + pub(super) subject: String, + pub(super) subject_type: String, + pub(super) predicate: String, + pub(super) object: String, + pub(super) object_type: String, + pub(super) confidence: f32, + /// Indices of the chunks where this relation was found. + pub(super) chunk_indexes: BTreeSet, + /// Global sequential index for ordering within the document. + pub(super) order_index: i64, + /// JSON metadata for the relation. + pub(super) metadata: Map, +} + +/// A single unit of text (sentence or chunk) passed to the extractor. +#[derive(Debug, Clone)] +pub(super) struct ExtractionUnit { + pub(super) text: String, + pub(super) chunk_index: usize, + pub(super) order_index: i64, +} + +/// Accumulates extraction results across multiple chunks or units. +/// +/// Handles entity and relation deduplication, alias tracking, and +/// basic document understanding (e.g., identifying the primary subject). +#[derive(Debug, Default)] +pub(super) struct ExtractionAccumulator { + /// Mapping of normalized entity name to its highest-confidence raw extraction. + pub(super) entities: HashMap, + /// Collected relations before final canonicalization. + pub(super) relations: Vec, + /// Tags identified during processing. + pub(super) tags: BTreeSet, + /// Decisions identified during processing. + pub(super) decisions: BTreeSet, + /// User preferences identified during processing. + pub(super) preferences: BTreeSet, + /// Inferred document kind (e.g., "profile"). + pub(super) doc_kind: Option, + /// The document's inferred primary subject. + pub(super) primary_subject: Option, + /// Sanitized document title. + pub(super) document_title: Option, + /// The subject of the current markdown section. + pub(super) current_subject: Option, + /// Current sender if processing a message/thread. + pub(super) current_sender: Option, + /// Mapping of names to their canonicalized full name. + pub(super) known_people: HashMap, +} + +/// The result of the parsing stage of ingestion. +#[derive(Debug)] +pub(super) struct ParsedIngestion { + pub(super) tags: Vec, + pub(super) metadata: Value, + pub(super) entities: Vec, + pub(super) relations: Vec, + pub(super) chunk_count: usize, + pub(super) preference_count: usize, + pub(super) decision_count: usize, +} diff --git a/src/openhuman/providers/compatible.rs b/src/openhuman/providers/compatible.rs index 22bfa7ac3..122e25b9e 100644 --- a/src/openhuman/providers/compatible.rs +++ b/src/openhuman/providers/compatible.rs @@ -2,6 +2,18 @@ //! Most LLM APIs follow the same `/v1/chat/completions` format. //! This module provides a single implementation that works for all of them. +#[path = "compatible_dump.rs"] +mod compatible_dump; +#[path = "compatible_parse.rs"] +mod compatible_parse; +#[path = "compatible_stream.rs"] +mod compatible_stream; +#[path = "compatible_types.rs"] +mod compatible_types; + +pub(crate) use compatible_parse::{parse_sse_line, strip_think_tags}; +pub(crate) use compatible_types::ResponsesResponse; + use crate::openhuman::providers::traits::{ ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse, Provider, StreamChunk, StreamError, StreamOptions, StreamResult, ToolCall as ProviderToolCall, @@ -13,122 +25,18 @@ use reqwest::{ header::{HeaderMap, HeaderValue, USER_AGENT}, Client, }; -use serde::{Deserialize, Serialize}; -use std::sync::atomic::{AtomicU64, Ordering}; -/// Monotonic sequence so multiple requests in the same millisecond sort -/// deterministically in the dump directory. -static PROMPT_DUMP_SEQ: AtomicU64 = AtomicU64::new(0); - -/// When `OPENHUMAN_PROMPT_DUMP_DIR` is set, write `body` (the exact JSON -/// payload we're about to POST to the provider) to a timestamped file -/// under that directory. Best-effort: failures are logged and swallowed -/// so a dump outage never breaks inference. -/// -/// Intended for KV-cache debugging — diff consecutive turns to see which -/// bytes of the prefix drifted and broke the cache hit. -/// Write raw response bytes to the dump dir paired with the most-recent -/// prompt file (same `seq` prefix, `.response.json` suffix). `seq` must -/// be the value reserved via `reserve_dump_seq` and passed to -/// `dump_prompt_if_enabled` so request/response files sort next to -/// each other. -fn dump_response_if_enabled(provider: &str, model: &str, seq: u64, bytes: &[u8]) { - let Ok(dir) = std::env::var("OPENHUMAN_PROMPT_DUMP_DIR") else { - return; - }; - let dir = std::path::PathBuf::from(dir); - if let Err(err) = std::fs::create_dir_all(&dir) { - log::warn!( - "[prompt_dump] failed to create dir {}: {err}", - dir.display() - ); - return; - } - let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ"); - let safe_model: String = model - .chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { - c - } else { - '_' - } - }) - .collect(); - let filename = format!("{ts}_{seq:06}_{provider}_{safe_model}.response.json"); - let path = dir.join(filename); - // Re-pretty-print if it parses as JSON so diffs are stable; otherwise - // write raw bytes (SSE fragments, error HTML, etc). - let payload: Vec = match serde_json::from_slice::(bytes) { - Ok(v) => serde_json::to_vec_pretty(&v).unwrap_or_else(|_| bytes.to_vec()), - Err(_) => bytes.to_vec(), - }; - if let Err(err) = std::fs::write(&path, &payload) { - log::warn!( - "[prompt_dump] response write failed {}: {err}", - path.display() - ); - } else { - log::debug!( - "[prompt_dump] wrote response {} bytes -> {}", - payload.len(), - path.display() - ); - } -} - -/// Atomically reserve the next dump sequence number. This is the single -/// source of truth for seq allocation — both the prompt dump and its -/// paired response dump must use the value returned here. A non-atomic -/// peek-then-increment split would race under concurrent requests (two -/// callers could reserve the same seq or correlate a request/response -/// pair across different turns). -fn reserve_dump_seq() -> u64 { - PROMPT_DUMP_SEQ.fetch_add(1, Ordering::Relaxed) -} - -fn dump_prompt_if_enabled(provider: &str, model: &str, seq: u64, body: &T) { - let Ok(dir) = std::env::var("OPENHUMAN_PROMPT_DUMP_DIR") else { - return; - }; - let dir = std::path::PathBuf::from(dir); - if let Err(err) = std::fs::create_dir_all(&dir) { - log::warn!( - "[prompt_dump] failed to create dir {}: {err}", - dir.display() - ); - return; - } - let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ"); - let safe_model: String = model - .chars() - .map(|c| { - if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { - c - } else { - '_' - } - }) - .collect(); - let filename = format!("{ts}_{seq:06}_{provider}_{safe_model}.json"); - let path = dir.join(filename); - match serde_json::to_vec_pretty(body) { - Ok(bytes) => { - if let Err(err) = std::fs::write(&path, &bytes) { - log::warn!("[prompt_dump] write failed {}: {err}", path.display()); - } else { - log::debug!( - "[prompt_dump] wrote {} bytes -> {}", - bytes.len(), - path.display() - ); - } - } - Err(err) => { - log::warn!("[prompt_dump] serialize failed: {err}"); - } - } -} +use compatible_dump::{dump_prompt_if_enabled, dump_response_if_enabled, reserve_dump_seq}; +use compatible_parse::{ + build_responses_prompt, extract_responses_text, normalize_function_arguments, + parse_chat_response_body, parse_responses_response_body, parse_tool_calls_from_content_json, +}; +use compatible_stream::sse_bytes_to_chunks; +use compatible_types::{ + ApiChatRequest, ApiChatResponse, ApiUsage, Choice, Function, Message, NativeChatRequest, + NativeMessage, OpenHumanMeta, ResponseMessage, ResponsesRequest, StreamChunkResponse, + StreamingToolCall, ToolCall, +}; /// A provider that speaks the OpenAI-compatible chat completions API. /// Used by: Venice, Vercel AI Gateway, Cloudflare AI Gateway, Moonshot, @@ -380,597 +288,7 @@ impl OpenAiCompatibleProvider { }) .collect() } -} -#[derive(Debug, Serialize)] -struct ApiChatRequest { - model: String, - messages: Vec, - temperature: f64, - #[serde(skip_serializing_if = "Option::is_none")] - stream: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - tool_choice: Option, -} - -#[derive(Debug, Serialize)] -struct Message { - role: String, - content: String, -} - -#[derive(Debug, Deserialize)] -struct ApiChatResponse { - choices: Vec, - /// Standard OpenAI usage block. - #[serde(default)] - usage: Option, - /// OpenHuman backend metadata (usage + billing summary). - #[serde(default)] - openhuman: Option, -} - -#[derive(Debug, Deserialize)] -struct Choice { - message: ResponseMessage, -} - -/// Standard OpenAI `usage` block on a chat completion response. -#[derive(Debug, Deserialize, Default)] -struct ApiUsage { - #[serde(default)] - prompt_tokens: u64, - #[serde(default)] - completion_tokens: u64, - #[serde(default)] - total_tokens: u64, - #[serde(default)] - prompt_tokens_details: Option, -} - -#[derive(Debug, Deserialize, Default)] -struct PromptTokensDetails { - #[serde(default)] - cached_tokens: u64, -} - -/// OpenHuman backend metadata appended to the response JSON. -#[derive(Debug, Deserialize, Default)] -struct OpenHumanMeta { - #[serde(default)] - usage: Option, - #[serde(default)] - billing: Option, -} - -#[derive(Debug, Deserialize, Default)] -struct OpenHumanUsage { - input_tokens: Option, - output_tokens: Option, - #[allow(dead_code)] - total_tokens: Option, - cached_input_tokens: Option, -} - -#[derive(Debug, Deserialize, Default)] -struct OpenHumanBilling { - #[serde(default)] - charged_amount_usd: f64, -} - -/// Remove `...` blocks from model output. -/// Some reasoning models (e.g. MiniMax) embed their chain-of-thought inline -/// in the `content` field rather than a separate `reasoning_content` field. -/// The resulting `` tags must be stripped before returning to the user. -fn strip_think_tags(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut rest = s; - loop { - if let Some(start) = rest.find("") { - result.push_str(&rest[..start]); - if let Some(end) = rest[start..].find("") { - rest = &rest[start + end + "".len()..]; - } else { - // Unclosed tag: drop the rest to avoid leaking partial reasoning. - break; - } - } else { - result.push_str(rest); - break; - } - } - result.trim().to_string() -} - -#[derive(Debug, Deserialize, Serialize)] -struct ResponseMessage { - #[serde(default)] - content: Option, - /// Reasoning/thinking models (e.g. Qwen3, GLM-4) may return their output - /// in `reasoning_content` instead of `content`. Used as automatic fallback. - #[serde(default)] - reasoning_content: Option, - #[serde(default)] - tool_calls: Option>, - #[serde(default)] - function_call: Option, -} - -impl ResponseMessage { - /// Extract text content, falling back to `reasoning_content` when `content` - /// is missing or empty. Reasoning/thinking models (Qwen3, GLM-4, etc.) - /// often return their output solely in `reasoning_content`. - /// Strips `...` blocks that some models (e.g. MiniMax) embed - /// inline in `content` instead of using a separate field. - fn effective_content(&self) -> String { - if let Some(content) = self.content.as_ref().filter(|c| !c.is_empty()) { - let stripped = strip_think_tags(content); - if !stripped.is_empty() { - return stripped; - } - } - - self.reasoning_content - .as_ref() - .map(|c| strip_think_tags(c)) - .filter(|c| !c.is_empty()) - .unwrap_or_default() - } - - fn effective_content_optional(&self) -> Option { - if let Some(content) = self.content.as_ref().filter(|c| !c.is_empty()) { - let stripped = strip_think_tags(content); - if !stripped.is_empty() { - return Some(stripped); - } - } - - self.reasoning_content - .as_ref() - .map(|c| strip_think_tags(c)) - .filter(|c| !c.is_empty()) - } -} - -#[derive(Debug, Deserialize, Serialize)] -struct ToolCall { - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(rename = "type")] - kind: Option, - function: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -struct Function { - name: Option, - arguments: Option, -} - -#[derive(Debug, Serialize)] -struct NativeChatRequest { - model: String, - messages: Vec, - temperature: f64, - #[serde(skip_serializing_if = "Option::is_none")] - stream: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - tool_choice: Option, -} - -#[derive(Debug, Serialize)] -struct NativeMessage { - role: String, - #[serde(skip_serializing_if = "Option::is_none")] - content: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tool_call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tool_calls: Option>, -} - -#[derive(Debug, Serialize)] -struct ResponsesRequest { - model: String, - input: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - stream: Option, -} - -#[derive(Debug, Serialize)] -struct ResponsesInput { - role: String, - content: String, -} - -#[derive(Debug, Deserialize)] -struct ResponsesResponse { - #[serde(default)] - output: Vec, - #[serde(default)] - output_text: Option, -} - -#[derive(Debug, Deserialize)] -struct ResponsesOutput { - #[serde(default)] - content: Vec, -} - -#[derive(Debug, Deserialize)] -struct ResponsesContent { - #[serde(rename = "type")] - kind: Option, - text: Option, -} - -// --------------------------------------------------------------- -// Streaming support (SSE parser) -// --------------------------------------------------------------- - -/// Server-Sent Event stream chunk for OpenAI-compatible streaming. -#[derive(Debug, Deserialize)] -struct StreamChunkResponse { - choices: Vec, - #[serde(default)] - usage: Option, - #[serde(default)] - openhuman: Option, -} - -#[derive(Debug, Deserialize)] -struct StreamChoice { - delta: StreamDelta, - #[allow(dead_code)] - finish_reason: Option, -} - -#[derive(Debug, Deserialize)] -struct StreamDelta { - #[serde(default)] - content: Option, - /// Reasoning/thinking models may stream output via `reasoning_content`. - #[serde(default)] - reasoning_content: Option, - /// Native tool-call chunks. Each entry is keyed by `index`; the first - /// chunk for a given index carries `id`/`type`/`function.name`, later - /// chunks only carry fragments of `function.arguments`. - #[serde(default)] - tool_calls: Option>, -} - -#[derive(Debug, Deserialize)] -struct StreamToolCallDelta { - /// Index of this tool call within the assistant message. Multiple - /// concurrent tool calls share the same message and are distinguished - /// by index — not id (which may only appear on the first chunk). - #[serde(default)] - index: Option, - #[serde(default)] - id: Option, - #[serde(default, rename = "type")] - #[allow(dead_code)] - kind: Option, - #[serde(default)] - function: Option, -} - -#[derive(Debug, Deserialize)] -struct StreamToolCallFunction { - #[serde(default)] - name: Option, - /// Arguments are streamed as a raw JSON string fragment; we accumulate - /// them as-is and only parse at the end of the stream. - #[serde(default)] - arguments: Option, -} - -/// Parse SSE (Server-Sent Events) stream from OpenAI-compatible providers. -/// Handles the `data: {...}` format and `[DONE]` sentinel. -fn parse_sse_line(line: &str) -> StreamResult> { - let line = line.trim(); - - // Skip empty lines and comments - if line.is_empty() || line.starts_with(':') { - return Ok(None); - } - - // SSE format: "data: {...}" - if let Some(data) = line.strip_prefix("data:") { - let data = data.trim(); - - // Check for [DONE] sentinel - if data == "[DONE]" { - return Ok(None); - } - - // Parse JSON delta - let chunk: StreamChunkResponse = serde_json::from_str(data).map_err(StreamError::Json)?; - - // Extract content from delta - if let Some(choice) = chunk.choices.first() { - if let Some(content) = &choice.delta.content { - if !content.is_empty() { - return Ok(Some(content.clone())); - } - } - // Fallback to reasoning_content for thinking models - if let Some(reasoning) = &choice.delta.reasoning_content { - return Ok(Some(reasoning.clone())); - } - } - } - - Ok(None) -} - -/// Convert SSE byte stream to text chunks. -fn sse_bytes_to_chunks( - response: reqwest::Response, - count_tokens: bool, -) -> stream::BoxStream<'static, StreamResult> { - // Create a channel to send chunks - let (tx, rx) = tokio::sync::mpsc::channel::>(100); - - tokio::spawn(async move { - // Buffer for incomplete lines - let mut buffer = String::new(); - - // Get response body as bytes stream - match response.error_for_status_ref() { - Ok(_) => {} - Err(e) => { - let _ = tx.send(Err(StreamError::Http(e))).await; - return; - } - } - - let mut bytes_stream = response.bytes_stream(); - - while let Some(item) = bytes_stream.next().await { - match item { - Ok(bytes) => { - // Convert bytes to string and process line by line - let text = match String::from_utf8(bytes.to_vec()) { - Ok(t) => t, - Err(e) => { - let _ = tx - .send(Err(StreamError::InvalidSse(format!( - "Invalid UTF-8: {}", - e - )))) - .await; - break; - } - }; - - buffer.push_str(&text); - - // Process complete lines - while let Some(pos) = buffer.find('\n') { - let line = buffer.drain(..=pos).collect::(); - buffer = buffer[pos + 1..].to_string(); - - match parse_sse_line(&line) { - Ok(Some(content)) => { - let mut chunk = StreamChunk::delta(content); - if count_tokens { - chunk = chunk.with_token_estimate(); - } - if tx.send(Ok(chunk)).await.is_err() { - return; // Receiver dropped - } - } - Ok(None) => {} - Err(e) => { - let _ = tx.send(Err(e)).await; - return; - } - } - } - } - Err(e) => { - let _ = tx.send(Err(StreamError::Http(e))).await; - break; - } - } - } - - // Send final chunk - let _ = tx.send(Ok(StreamChunk::final_chunk())).await; - }); - - // Convert channel receiver to stream - stream::unfold(rx, |mut rx| async { - rx.recv().await.map(|chunk| (chunk, rx)) - }) - .boxed() -} - -fn first_nonempty(text: Option<&str>) -> Option { - text.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -fn normalize_responses_role(role: &str) -> &'static str { - match role { - "assistant" => "assistant", - "tool" => "assistant", - _ => "user", - } -} - -fn build_responses_prompt(messages: &[ChatMessage]) -> (Option, Vec) { - let mut instructions_parts = Vec::new(); - let mut input = Vec::new(); - - for message in messages { - if message.content.trim().is_empty() { - continue; - } - - if message.role == "system" { - instructions_parts.push(message.content.clone()); - continue; - } - - input.push(ResponsesInput { - role: normalize_responses_role(&message.role).to_string(), - content: message.content.clone(), - }); - } - - let instructions = if instructions_parts.is_empty() { - None - } else { - Some(instructions_parts.join("\n\n")) - }; - - (instructions, input) -} - -fn extract_responses_text(response: ResponsesResponse) -> Option { - if let Some(text) = first_nonempty(response.output_text.as_deref()) { - return Some(text); - } - - for item in &response.output { - for content in &item.content { - if content.kind.as_deref() == Some("output_text") { - if let Some(text) = first_nonempty(content.text.as_deref()) { - return Some(text); - } - } - } - } - - for item in &response.output { - for content in &item.content { - if let Some(text) = first_nonempty(content.text.as_deref()) { - return Some(text); - } - } - } - - None -} - -fn compact_sanitized_body_snippet(body: &str) -> String { - super::sanitize_api_error(body) - .split_whitespace() - .collect::>() - .join(" ") -} - -fn parse_chat_response_body(provider_name: &str, body: &str) -> anyhow::Result { - serde_json::from_str::(body).map_err(|error| { - let snippet = compact_sanitized_body_snippet(body); - anyhow::anyhow!( - "{provider_name} API returned an unexpected chat-completions payload: {error}; body={snippet}" - ) - }) -} - -fn normalize_function_arguments(arguments: Option) -> String { - match arguments { - Some(serde_json::Value::String(raw)) => { - if raw.trim().is_empty() { - "{}".to_string() - } else { - raw - } - } - Some(serde_json::Value::Null) | None => "{}".to_string(), - Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "{}".to_string()), - } -} - -fn parse_provider_tool_call_from_value(value: &serde_json::Value) -> Option { - if let Ok(call) = serde_json::from_value::(value.clone()) { - if !call.name.trim().is_empty() { - return Some(ProviderToolCall { - id: if call.id.trim().is_empty() { - uuid::Uuid::new_v4().to_string() - } else { - call.id - }, - name: call.name, - arguments: if call.arguments.trim().is_empty() { - "{}".to_string() - } else { - call.arguments - }, - }); - } - } - - let function = value.get("function")?; - let name = function.get("name").and_then(serde_json::Value::as_str)?; - if name.trim().is_empty() { - return None; - } - - let id = value - .get("id") - .and_then(serde_json::Value::as_str) - .map(ToString::to_string) - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - - Some(ProviderToolCall { - id, - name: name.to_string(), - arguments: normalize_function_arguments(function.get("arguments").cloned()), - }) -} - -fn parse_tool_calls_from_content_json( - content: &str, -) -> Option<(Option, Vec)> { - let value = serde_json::from_str::(content).ok()?; - let tool_calls_value = value.get("tool_calls")?.as_array()?; - let tool_calls: Vec = tool_calls_value - .iter() - .filter_map(parse_provider_tool_call_from_value) - .collect(); - if tool_calls.is_empty() { - return None; - } - - let text = value - .get("content") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToString::to_string); - - Some((text, tool_calls)) -} - -fn parse_responses_response_body( - provider_name: &str, - body: &str, -) -> anyhow::Result { - serde_json::from_str::(body).map_err(|error| { - let snippet = compact_sanitized_body_snippet(body); - anyhow::anyhow!( - "{provider_name} Responses API returned an unexpected payload: {error}; body={snippet}" - ) - }) -} - -impl OpenAiCompatibleProvider { fn apply_auth_header( &self, req: reqwest::RequestBuilder, @@ -1063,7 +381,9 @@ impl OpenAiCompatibleProvider { kind: Some("function".to_string()), function: Some(Function { name: Some(tc.name), - arguments: Some(serde_json::Value::String(tc.arguments)), + arguments: Some(serde_json::Value::String( + tc.arguments, + )), }), }) .collect::>(); @@ -1085,7 +405,9 @@ impl OpenAiCompatibleProvider { } if message.role == "tool" { - if let Ok(value) = serde_json::from_str::(&message.content) { + if let Ok(value) = + serde_json::from_str::(&message.content) + { let tool_call_id = value .get("tool_call_id") .and_then(serde_json::Value::as_str) @@ -1662,24 +984,6 @@ impl OpenAiCompatibleProvider { } } -/// Per-index tool-call accumulator used while consuming an SSE stream. -/// -/// `arguments` holds the full cumulative JSON text fragments seen so -/// far. `emitted_start` tracks whether we've surfaced the synthetic -/// `ProviderDelta::ToolCallStart` event yet (we only do once we know -/// both `id` and `name`). `emitted_chars` is the byte offset within -/// `arguments` that we've already flushed as `ToolCallArgsDelta` -/// events — used to avoid re-sending buffered fragments after the -/// start event fires. -#[derive(Debug, Default)] -struct StreamingToolCall { - id: Option, - name: Option, - arguments: String, - emitted_start: bool, - emitted_chars: usize, -} - #[async_trait] impl Provider for OpenAiCompatibleProvider { fn capabilities(&self) -> crate::openhuman::providers::traits::ProviderCapabilities { diff --git a/src/openhuman/providers/compatible_dump.rs b/src/openhuman/providers/compatible_dump.rs new file mode 100644 index 000000000..0680b4990 --- /dev/null +++ b/src/openhuman/providers/compatible_dump.rs @@ -0,0 +1,128 @@ +//! Prompt and response dump utilities for KV-cache debugging. +//! +//! When `OPENHUMAN_PROMPT_DUMP_DIR` is set, both the outbound request payload +//! and the inbound response body are written to timestamped files under that +//! directory. Best-effort: failures are logged and swallowed so a dump outage +//! never breaks inference. + +use serde::Serialize; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Monotonic sequence so multiple requests in the same millisecond sort +/// deterministically in the dump directory. +pub(crate) static PROMPT_DUMP_SEQ: AtomicU64 = AtomicU64::new(0); + +/// Atomically reserve the next dump sequence number. This is the single +/// source of truth for seq allocation — both the prompt dump and its +/// paired response dump must use the value returned here. A non-atomic +/// peek-then-increment split would race under concurrent requests (two +/// callers could reserve the same seq or correlate a request/response +/// pair across different turns). +pub(crate) fn reserve_dump_seq() -> u64 { + PROMPT_DUMP_SEQ.fetch_add(1, Ordering::Relaxed) +} + +/// When `OPENHUMAN_PROMPT_DUMP_DIR` is set, write `body` (the exact JSON +/// payload we're about to POST to the provider) to a timestamped file +/// under that directory. Best-effort: failures are logged and swallowed +/// so a dump outage never breaks inference. +/// +/// Intended for KV-cache debugging — diff consecutive turns to see which +/// bytes of the prefix drifted and broke the cache hit. +pub(crate) fn dump_prompt_if_enabled( + provider: &str, + model: &str, + seq: u64, + body: &T, +) { + let Ok(dir) = std::env::var("OPENHUMAN_PROMPT_DUMP_DIR") else { + return; + }; + let dir = std::path::PathBuf::from(dir); + if let Err(err) = std::fs::create_dir_all(&dir) { + log::warn!( + "[prompt_dump] failed to create dir {}: {err}", + dir.display() + ); + return; + } + let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ"); + let safe_model: String = model + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + let filename = format!("{ts}_{seq:06}_{provider}_{safe_model}.json"); + let path = dir.join(filename); + match serde_json::to_vec_pretty(body) { + Ok(bytes) => { + if let Err(err) = std::fs::write(&path, &bytes) { + log::warn!("[prompt_dump] write failed {}: {err}", path.display()); + } else { + log::debug!( + "[prompt_dump] wrote {} bytes -> {}", + bytes.len(), + path.display() + ); + } + } + Err(err) => { + log::warn!("[prompt_dump] serialize failed: {err}"); + } + } +} + +/// Write raw response bytes to the dump dir paired with the most-recent +/// prompt file (same `seq` prefix, `.response.json` suffix). `seq` must +/// be the value reserved via `reserve_dump_seq` and passed to +/// `dump_prompt_if_enabled` so request/response files sort next to +/// each other. +pub(crate) fn dump_response_if_enabled(provider: &str, model: &str, seq: u64, bytes: &[u8]) { + let Ok(dir) = std::env::var("OPENHUMAN_PROMPT_DUMP_DIR") else { + return; + }; + let dir = std::path::PathBuf::from(dir); + if let Err(err) = std::fs::create_dir_all(&dir) { + log::warn!( + "[prompt_dump] failed to create dir {}: {err}", + dir.display() + ); + return; + } + let ts = chrono::Utc::now().format("%Y%m%dT%H%M%S%.3fZ"); + let safe_model: String = model + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + let filename = format!("{ts}_{seq:06}_{provider}_{safe_model}.response.json"); + let path = dir.join(filename); + // Re-pretty-print if it parses as JSON so diffs are stable; otherwise + // write raw bytes (SSE fragments, error HTML, etc). + let payload: Vec = match serde_json::from_slice::(bytes) { + Ok(v) => serde_json::to_vec_pretty(&v).unwrap_or_else(|_| bytes.to_vec()), + Err(_) => bytes.to_vec(), + }; + if let Err(err) = std::fs::write(&path, &payload) { + log::warn!( + "[prompt_dump] response write failed {}: {err}", + path.display() + ); + } else { + log::debug!( + "[prompt_dump] wrote response {} bytes -> {}", + payload.len(), + path.display() + ); + } +} diff --git a/src/openhuman/providers/compatible_parse.rs b/src/openhuman/providers/compatible_parse.rs new file mode 100644 index 000000000..7a5c1662a --- /dev/null +++ b/src/openhuman/providers/compatible_parse.rs @@ -0,0 +1,270 @@ +//! Parsing and response-extraction free functions for the OpenAI-compatible provider. +//! +//! All functions here are stateless transforms — no I/O, no HTTP. They take +//! raw strings or deserialized values and return structured results. + +use crate::openhuman::providers::traits::{ + ChatMessage, StreamError, StreamResult, ToolCall as ProviderToolCall, +}; + +use super::compatible_types::{ + ApiChatResponse, ResponsesInput, ResponsesResponse, StreamChunkResponse, +}; + +// ── Think-tag stripping ─────────────────────────────────────────────────────── + +/// Remove `...` blocks from model output. +/// Some reasoning models (e.g. MiniMax) embed their chain-of-thought inline +/// in the `content` field rather than a separate `reasoning_content` field. +/// The resulting `` tags must be stripped before returning to the user. +pub(crate) fn strip_think_tags(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut rest = s; + loop { + if let Some(start) = rest.find("") { + result.push_str(&rest[..start]); + if let Some(end) = rest[start..].find("") { + rest = &rest[start + end + "".len()..]; + } else { + // Unclosed tag: drop the rest to avoid leaking partial reasoning. + break; + } + } else { + result.push_str(rest); + break; + } + } + result.trim().to_string() +} + +// ── SSE line parser ─────────────────────────────────────────────────────────── + +/// Parse a single SSE (Server-Sent Events) line from OpenAI-compatible providers. +/// Handles the `data: {...}` format and `[DONE]` sentinel. +pub(crate) fn parse_sse_line(line: &str) -> StreamResult> { + let line = line.trim(); + + // Skip empty lines and comments + if line.is_empty() || line.starts_with(':') { + return Ok(None); + } + + // SSE format: "data: {...}" + if let Some(data) = line.strip_prefix("data:") { + let data = data.trim(); + + // Check for [DONE] sentinel + if data == "[DONE]" { + return Ok(None); + } + + // Parse JSON delta + let chunk: StreamChunkResponse = serde_json::from_str(data).map_err(StreamError::Json)?; + + // Extract content from delta + if let Some(choice) = chunk.choices.first() { + if let Some(content) = &choice.delta.content { + if !content.is_empty() { + return Ok(Some(content.clone())); + } + } + // Fallback to reasoning_content for thinking models + if let Some(reasoning) = &choice.delta.reasoning_content { + return Ok(Some(reasoning.clone())); + } + } + } + + Ok(None) +} + +// ── Response body parsers ───────────────────────────────────────────────────── + +pub(crate) fn compact_sanitized_body_snippet(body: &str) -> String { + // super = compatible module; super::super = providers module (where sanitize_api_error lives) + super::super::sanitize_api_error(body) + .split_whitespace() + .collect::>() + .join(" ") +} + +pub(crate) fn parse_chat_response_body( + provider_name: &str, + body: &str, +) -> anyhow::Result { + serde_json::from_str::(body).map_err(|error| { + let snippet = compact_sanitized_body_snippet(body); + anyhow::anyhow!( + "{provider_name} API returned an unexpected chat-completions payload: {error}; body={snippet}" + ) + }) +} + +pub(crate) fn parse_responses_response_body( + provider_name: &str, + body: &str, +) -> anyhow::Result { + serde_json::from_str::(body).map_err(|error| { + let snippet = compact_sanitized_body_snippet(body); + anyhow::anyhow!( + "{provider_name} Responses API returned an unexpected payload: {error}; body={snippet}" + ) + }) +} + +// ── Tool-call argument normalisation ───────────────────────────────────────── + +pub(crate) fn normalize_function_arguments(arguments: Option) -> String { + match arguments { + Some(serde_json::Value::String(raw)) => { + if raw.trim().is_empty() { + "{}".to_string() + } else { + raw + } + } + Some(serde_json::Value::Null) | None => "{}".to_string(), + Some(other) => serde_json::to_string(&other).unwrap_or_else(|_| "{}".to_string()), + } +} + +pub(crate) fn parse_provider_tool_call_from_value( + value: &serde_json::Value, +) -> Option { + if let Ok(call) = serde_json::from_value::(value.clone()) { + if !call.name.trim().is_empty() { + return Some(ProviderToolCall { + id: if call.id.trim().is_empty() { + uuid::Uuid::new_v4().to_string() + } else { + call.id + }, + name: call.name, + arguments: if call.arguments.trim().is_empty() { + "{}".to_string() + } else { + call.arguments + }, + }); + } + } + + let function = value.get("function")?; + let name = function.get("name").and_then(serde_json::Value::as_str)?; + if name.trim().is_empty() { + return None; + } + + let id = value + .get("id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + Some(ProviderToolCall { + id, + name: name.to_string(), + arguments: normalize_function_arguments(function.get("arguments").cloned()), + }) +} + +pub(crate) fn parse_tool_calls_from_content_json( + content: &str, +) -> Option<(Option, Vec)> { + let value = serde_json::from_str::(content).ok()?; + let tool_calls_value = value.get("tool_calls")?.as_array()?; + let tool_calls: Vec = tool_calls_value + .iter() + .filter_map(parse_provider_tool_call_from_value) + .collect(); + if tool_calls.is_empty() { + return None; + } + + let text = value + .get("content") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string); + + Some((text, tool_calls)) +} + +// ── Responses API helpers ───────────────────────────────────────────────────── + +pub(crate) fn first_nonempty(text: Option<&str>) -> Option { + text.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +pub(crate) fn normalize_responses_role(role: &str) -> &'static str { + match role { + "assistant" => "assistant", + "tool" => "assistant", + _ => "user", + } +} + +pub(crate) fn build_responses_prompt( + messages: &[ChatMessage], +) -> (Option, Vec) { + let mut instructions_parts = Vec::new(); + let mut input = Vec::new(); + + for message in messages { + if message.content.trim().is_empty() { + continue; + } + + if message.role == "system" { + instructions_parts.push(message.content.clone()); + continue; + } + + input.push(ResponsesInput { + role: normalize_responses_role(&message.role).to_string(), + content: message.content.clone(), + }); + } + + let instructions = if instructions_parts.is_empty() { + None + } else { + Some(instructions_parts.join("\n\n")) + }; + + (instructions, input) +} + +pub(crate) fn extract_responses_text(response: ResponsesResponse) -> Option { + if let Some(text) = first_nonempty(response.output_text.as_deref()) { + return Some(text); + } + + for item in &response.output { + for content in &item.content { + if content.kind.as_deref() == Some("output_text") { + if let Some(text) = first_nonempty(content.text.as_deref()) { + return Some(text); + } + } + } + } + + for item in &response.output { + for content in &item.content { + if let Some(text) = first_nonempty(content.text.as_deref()) { + return Some(text); + } + } + } + + None +} diff --git a/src/openhuman/providers/compatible_stream.rs b/src/openhuman/providers/compatible_stream.rs new file mode 100644 index 000000000..172ebe9ce --- /dev/null +++ b/src/openhuman/providers/compatible_stream.rs @@ -0,0 +1,92 @@ +//! SSE streaming support for the OpenAI-compatible provider. +//! +//! Converts a raw `reqwest::Response` byte stream into a typed +//! `StreamChunk` stream via Server-Sent Events parsing. + +use crate::openhuman::providers::traits::{StreamChunk, StreamError, StreamResult}; +use futures_util::{stream, StreamExt}; + +use super::compatible_parse::parse_sse_line; + +/// Convert SSE byte stream to text chunks. +pub(crate) fn sse_bytes_to_chunks( + response: reqwest::Response, + count_tokens: bool, +) -> stream::BoxStream<'static, StreamResult> { + // Create a channel to send chunks + let (tx, rx) = tokio::sync::mpsc::channel::>(100); + + tokio::spawn(async move { + // Buffer for incomplete lines + let mut buffer = String::new(); + + // Get response body as bytes stream + match response.error_for_status_ref() { + Ok(_) => {} + Err(e) => { + let _ = tx.send(Err(StreamError::Http(e))).await; + return; + } + } + + let mut bytes_stream = response.bytes_stream(); + + while let Some(item) = bytes_stream.next().await { + match item { + Ok(bytes) => { + // Convert bytes to string and process line by line + let text = match String::from_utf8(bytes.to_vec()) { + Ok(t) => t, + Err(e) => { + let _ = tx + .send(Err(StreamError::InvalidSse(format!( + "Invalid UTF-8: {}", + e + )))) + .await; + break; + } + }; + + buffer.push_str(&text); + + // Process complete lines + while let Some(pos) = buffer.find('\n') { + let line = buffer.drain(..=pos).collect::(); + buffer = buffer[pos + 1..].to_string(); + + match parse_sse_line(&line) { + Ok(Some(content)) => { + let mut chunk = StreamChunk::delta(content); + if count_tokens { + chunk = chunk.with_token_estimate(); + } + if tx.send(Ok(chunk)).await.is_err() { + return; // Receiver dropped + } + } + Ok(None) => {} + Err(e) => { + let _ = tx.send(Err(e)).await; + return; + } + } + } + } + Err(e) => { + let _ = tx.send(Err(StreamError::Http(e))).await; + break; + } + } + } + + // Send final chunk + let _ = tx.send(Ok(StreamChunk::final_chunk())).await; + }); + + // Convert channel receiver to stream + stream::unfold(rx, |mut rx| async { + rx.recv().await.map(|chunk| (chunk, rx)) + }) + .boxed() +} diff --git a/src/openhuman/providers/compatible_types.rs b/src/openhuman/providers/compatible_types.rs new file mode 100644 index 000000000..f0ab8b471 --- /dev/null +++ b/src/openhuman/providers/compatible_types.rs @@ -0,0 +1,292 @@ +//! Serde request/response structs for the OpenAI-compatible provider. +//! +//! All types in this module are crate-internal (`pub(crate)` or `pub(crate)` +//! as appropriate). External code only sees the public API on +//! [`super::OpenAiCompatibleProvider`]. + +use serde::{Deserialize, Serialize}; + +// ── Request bodies ──────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub(crate) struct ApiChatRequest { + pub(crate) model: String, + pub(crate) messages: Vec, + pub(crate) temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool_choice: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Message { + pub(crate) role: String, + pub(crate) content: String, +} + +#[derive(Debug, Serialize)] +pub(crate) struct NativeChatRequest { + pub(crate) model: String, + pub(crate) messages: Vec, + pub(crate) temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool_choice: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct NativeMessage { + pub(crate) role: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool_calls: Option>, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ResponsesRequest { + pub(crate) model: String, + pub(crate) input: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) stream: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ResponsesInput { + pub(crate) role: String, + pub(crate) content: String, +} + +// ── Response bodies ─────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub(crate) struct ApiChatResponse { + pub(crate) choices: Vec, + /// Standard OpenAI usage block. + #[serde(default)] + pub(crate) usage: Option, + /// OpenHuman backend metadata (usage + billing summary). + #[serde(default)] + pub(crate) openhuman: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct Choice { + pub(crate) message: ResponseMessage, +} + +/// Standard OpenAI `usage` block on a chat completion response. +#[derive(Debug, Deserialize, Default)] +pub(crate) struct ApiUsage { + #[serde(default)] + pub(crate) prompt_tokens: u64, + #[serde(default)] + pub(crate) completion_tokens: u64, + #[serde(default)] + pub(crate) total_tokens: u64, + #[serde(default)] + pub(crate) prompt_tokens_details: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub(crate) struct PromptTokensDetails { + #[serde(default)] + pub(crate) cached_tokens: u64, +} + +/// OpenHuman backend metadata appended to the response JSON. +#[derive(Debug, Deserialize, Default)] +pub(crate) struct OpenHumanMeta { + #[serde(default)] + pub(crate) usage: Option, + #[serde(default)] + pub(crate) billing: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub(crate) struct OpenHumanUsage { + pub(crate) input_tokens: Option, + pub(crate) output_tokens: Option, + #[allow(dead_code)] + pub(crate) total_tokens: Option, + pub(crate) cached_input_tokens: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub(crate) struct OpenHumanBilling { + #[serde(default)] + pub(crate) charged_amount_usd: f64, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct ResponseMessage { + #[serde(default)] + pub(crate) content: Option, + /// Reasoning/thinking models (e.g. Qwen3, GLM-4) may return their output + /// in `reasoning_content` instead of `content`. Used as automatic fallback. + #[serde(default)] + pub(crate) reasoning_content: Option, + #[serde(default)] + pub(crate) tool_calls: Option>, + #[serde(default)] + pub(crate) function_call: Option, +} + +impl ResponseMessage { + /// Extract text content, falling back to `reasoning_content` when `content` + /// is missing or empty. Reasoning/thinking models (Qwen3, GLM-4, etc.) + /// often return their output solely in `reasoning_content`. + /// Strips `...` blocks that some models (e.g. MiniMax) embed + /// inline in `content` instead of using a separate field. + pub(crate) fn effective_content(&self) -> String { + if let Some(content) = self.content.as_ref().filter(|c| !c.is_empty()) { + let stripped = super::compatible_parse::strip_think_tags(content); + if !stripped.is_empty() { + return stripped; + } + } + + self.reasoning_content + .as_ref() + .map(|c| super::compatible_parse::strip_think_tags(c)) + .filter(|c| !c.is_empty()) + .unwrap_or_default() + } + + pub(crate) fn effective_content_optional(&self) -> Option { + if let Some(content) = self.content.as_ref().filter(|c| !c.is_empty()) { + let stripped = super::compatible_parse::strip_think_tags(content); + if !stripped.is_empty() { + return Some(stripped); + } + } + + self.reasoning_content + .as_ref() + .map(|c| super::compatible_parse::strip_think_tags(c)) + .filter(|c| !c.is_empty()) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct ToolCall { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) id: Option, + #[serde(rename = "type")] + pub(crate) kind: Option, + pub(crate) function: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct Function { + pub(crate) name: Option, + pub(crate) arguments: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ResponsesResponse { + #[serde(default)] + pub(crate) output: Vec, + #[serde(default)] + pub(crate) output_text: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ResponsesOutput { + #[serde(default)] + pub(crate) content: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ResponsesContent { + #[serde(rename = "type")] + pub(crate) kind: Option, + pub(crate) text: Option, +} + +// ── Streaming types ─────────────────────────────────────────────────────────── + +/// Server-Sent Event stream chunk for OpenAI-compatible streaming. +#[derive(Debug, Deserialize)] +pub(crate) struct StreamChunkResponse { + pub(crate) choices: Vec, + #[serde(default)] + pub(crate) usage: Option, + #[serde(default)] + pub(crate) openhuman: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct StreamChoice { + pub(crate) delta: StreamDelta, + #[allow(dead_code)] + pub(crate) finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct StreamDelta { + #[serde(default)] + pub(crate) content: Option, + /// Reasoning/thinking models may stream output via `reasoning_content`. + #[serde(default)] + pub(crate) reasoning_content: Option, + /// Native tool-call chunks. Each entry is keyed by `index`; the first + /// chunk for a given index carries `id`/`type`/`function.name`, later + /// chunks only carry fragments of `function.arguments`. + #[serde(default)] + pub(crate) tool_calls: Option>, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct StreamToolCallDelta { + /// Index of this tool call within the assistant message. Multiple + /// concurrent tool calls share the same message and are distinguished + /// by index — not id (which may only appear on the first chunk). + #[serde(default)] + pub(crate) index: Option, + #[serde(default)] + pub(crate) id: Option, + #[serde(default, rename = "type")] + #[allow(dead_code)] + pub(crate) kind: Option, + #[serde(default)] + pub(crate) function: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct StreamToolCallFunction { + #[serde(default)] + pub(crate) name: Option, + /// Arguments are streamed as a raw JSON string fragment; we accumulate + /// them as-is and only parse at the end of the stream. + #[serde(default)] + pub(crate) arguments: Option, +} + +/// Per-index tool-call accumulator used while consuming an SSE stream. +/// +/// `arguments` holds the full cumulative JSON text fragments seen so +/// far. `emitted_start` tracks whether we've surfaced the synthetic +/// `ProviderDelta::ToolCallStart` event yet (we only do once we know +/// both `id` and `name`). `emitted_chars` is the byte offset within +/// `arguments` that we've already flushed as `ToolCallArgsDelta` +/// events — used to avoid re-sending buffered fragments after the +/// start event fires. +#[derive(Debug, Default)] +pub(crate) struct StreamingToolCall { + pub(crate) id: Option, + pub(crate) name: Option, + pub(crate) arguments: String, + pub(crate) emitted_start: bool, + pub(crate) emitted_chars: usize, +} diff --git a/src/openhuman/skills/mod.rs b/src/openhuman/skills/mod.rs index 48418d65b..85092e291 100644 --- a/src/openhuman/skills/mod.rs +++ b/src/openhuman/skills/mod.rs @@ -3,6 +3,11 @@ pub mod bus; pub mod inject; pub mod ops; +pub mod ops_create; +pub mod ops_discover; +pub mod ops_install; +pub mod ops_parse; +pub mod ops_types; pub mod schemas; pub mod types; diff --git a/src/openhuman/skills/ops.rs b/src/openhuman/skills/ops.rs index 699036ed1..f80a61ad2 100644 --- a/src/openhuman/skills/ops.rs +++ b/src/openhuman/skills/ops.rs @@ -16,1935 +16,42 @@ //! //! Legacy `skill.json` manifests and the flat `/skills//` //! layout are still supported for backward compatibility. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::net::{Ipv4Addr, Ipv6Addr}; -use std::path::{Path, PathBuf}; - -const TRUST_MARKER: &str = "trust"; -const SKILL_MD: &str = "SKILL.md"; -const SKILL_JSON: &str = "skill.json"; -const MAX_NAME_LEN: usize = 64; -const MAX_DESCRIPTION_LEN: usize = 1024; -const RESOURCE_DIRS: &[&str] = &["scripts", "references", "assets"]; - -/// Upper bound on resource payload size (in bytes) returned by -/// [`read_skill_resource`]. 128 KB is large enough for a typical SKILL-bundled -/// script or reference doc but small enough to keep the JSON-RPC payload and -/// UI memory footprint bounded even when a skill author bundles something -/// unusually chonky (e.g. a minified binary fixture). Requests for files -/// larger than this limit are rejected outright — callers must stream or -/// download the file via another mechanism. -pub const MAX_SKILL_RESOURCE_BYTES: u64 = 128 * 1024; - -/// Where the skill was discovered. Determines precedence on name collision. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SkillScope { - /// Skill shipped with the user's global config (`~/.openhuman/skills/...`). - User, - /// Skill shipped with the current workspace (`/.openhuman/skills/...`). - /// Requires the trust marker to be loaded. - Project, - /// Skill discovered under the legacy `/skills/` layout. - Legacy, -} - -impl Default for SkillScope { - fn default() -> Self { - Self::User - } -} - -/// Parsed frontmatter of a `SKILL.md` file. -/// -/// Matches the agentskills.io SKILL.md spec: `name` and `description` are -/// required; `license`, `compatibility`, `metadata`, and `allowed-tools` are -/// optional. Spec additions land in [`Self::extra`] via `#[serde(flatten)]`. -/// -/// Version, author, tags, and other non-required fields belong under -/// [`Self::metadata`]. Writers that still put them at the top level are -/// accepted with a migration warning. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SkillFrontmatter { - #[serde(default)] - pub name: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub license: Option, - #[serde(default)] - pub compatibility: Option, - /// Spec-compliant metadata map. Version, author, tags, and other - /// non-required fields live here. - #[serde(default)] - pub metadata: HashMap, - /// Tools the skill author asserts their instructions rely on - /// (non-binding hint; the host decides what to expose). - #[serde(default, rename = "allowed-tools", alias = "allowed_tools")] - pub allowed_tools: Vec, - /// Forward-compat hatch for spec additions. Non-spec top-level keys - /// (including legacy `version`, `author`, `tags`) land here and trigger - /// a migration warning when read. - #[serde(flatten)] - pub extra: HashMap, -} - -fn metadata_string(fm: &SkillFrontmatter, key: &str) -> Option { - fm.metadata - .get(key) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) -} - -fn metadata_string_seq(value: &serde_yaml::Value) -> Vec { - value - .as_sequence() - .map(|seq| { - seq.iter() - .filter_map(|t| t.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default() -} - -fn extract_version(fm: &SkillFrontmatter, warnings: &mut Vec) -> String { - if let Some(v) = metadata_string(fm, "version") { - return v; - } - if let Some(v) = fm.extra.get("version").and_then(|v| v.as_str()) { - log::warn!("[skills] top-level 'version' is deprecated; move under 'metadata.version'"); - warnings - .push("top-level 'version' is deprecated; move under 'metadata.version'".to_string()); - return v.to_string(); - } - String::new() -} - -fn extract_author(fm: &SkillFrontmatter, warnings: &mut Vec) -> Option { - if let Some(v) = metadata_string(fm, "author") { - return Some(v); - } - if let Some(v) = fm.extra.get("author").and_then(|v| v.as_str()) { - log::warn!("[skills] top-level 'author' is deprecated; move under 'metadata.author'"); - warnings.push("top-level 'author' is deprecated; move under 'metadata.author'".to_string()); - return Some(v.to_string()); - } - None -} - -fn extract_tags(fm: &SkillFrontmatter, warnings: &mut Vec) -> Vec { - if let Some(v) = fm.metadata.get("tags") { - return metadata_string_seq(v); - } - if let Some(v) = fm.extra.get("tags") { - log::warn!("[skills] top-level 'tags' is deprecated; move under 'metadata.tags'"); - warnings.push("top-level 'tags' is deprecated; move under 'metadata.tags'".to_string()); - return metadata_string_seq(v); - } - Vec::new() -} - -/// A discovered skill. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct Skill { - /// Display name (from frontmatter, falls back to directory name). - pub name: String, - /// On-disk slug — the directory name under `~/.openhuman/skills/` (user - /// scope) or the workspace skills directory (project scope). This is the - /// identifier the uninstall RPC resolves against; it may differ from - /// [`Skill::name`] when frontmatter declares a mismatched display name. - #[serde(default)] - pub dir_name: String, - /// Short description used in the catalog summary. - pub description: String, - /// Version string, if declared. - pub version: String, - /// Author string, if declared. - pub author: Option, - /// Tags declared in frontmatter. - pub tags: Vec, - /// Tool hint declared in frontmatter (`allowed-tools`). - #[serde(default)] - pub tools: Vec, - /// Prompt files declared in legacy `skill.json`. Unused for SKILL.md skills. - #[serde(default)] - pub prompts: Vec, - /// Path to the `SKILL.md` (or `skill.json`) file. - pub location: Option, - /// Full parsed frontmatter when sourced from `SKILL.md`. - #[serde(default)] - pub frontmatter: SkillFrontmatter, - /// Bundled resource files (relative to the skill directory). - #[serde(default)] - pub resources: Vec, - /// Where the skill came from. - #[serde(default)] - pub scope: SkillScope, - /// True when loaded from the legacy `skill.json` / `/skills/` layout. - #[serde(default)] - pub legacy: bool, - /// Non-fatal parse warnings, surfaced in the catalog for user debugging. - #[serde(default)] - pub warnings: Vec, -} - -impl Skill { - /// Re-read the SKILL.md body (everything after the YAML frontmatter - /// block) from disk. Returns `None` for legacy `skill.json` skills, - /// for skills whose `location` points nowhere, or when the file - /// cannot be parsed as a SKILL.md document. - /// - /// Used by the inference-time injector in - /// [`crate::openhuman::skills::inject`] — the body is the actual - /// instruction block that gets prepended to a user message when a - /// skill matches. Kept off the main `Skill` struct deliberately so - /// the catalog list stays cheap to construct; only the subset of - /// skills that match a turn pay the disk-read cost. - pub fn read_body(&self) -> Option { - if self.legacy { - log::debug!( - "[skills:inject] read_body skipped for legacy skill.json skill name={}", - self.name - ); - return None; - } - let path = self.location.as_ref()?; - match parse_skill_md(path) { - Some((_, body, _)) => Some(body), - None => { - log::warn!( - "[skills:inject] read_body failed to parse {} for skill {}", - path.display(), - self.name - ); - None - } - } - } -} - -/// Internal structure for parsing legacy `skill.json` manifests. -#[derive(Debug, Deserialize)] -struct LegacySkillManifest { - #[serde(default)] - name: String, - #[serde(default)] - description: String, - #[serde(default)] - version: String, - #[serde(default)] - author: Option, - #[serde(default)] - tags: Vec, - #[serde(default)] - tools: Vec, - #[serde(default)] - prompts: Vec, -} - -/// Initialize the legacy skills directory in the specified workspace. -/// -/// Creates `/skills/` and a placeholder `README.md` so the folder -/// is visible to the user. New-style skills should live under -/// `/.openhuman/skills/` instead, but this directory is kept for -/// backward compatibility. -pub fn init_skills_dir(workspace_dir: &Path) -> Result<(), String> { - let skills_dir = workspace_dir.join("skills"); - std::fs::create_dir_all(&skills_dir).map_err(|e| { - format!( - "failed to create skills directory {}: {e}", - skills_dir.display() - ) - })?; - - let readme_path = skills_dir.join("README.md"); - if !readme_path.exists() { - let content = "# Skills\n\nPut one skill per directory under this folder.\n"; - std::fs::write(&readme_path, content) - .map_err(|e| format!("failed to write {}: {e}", readme_path.display()))?; - } - - Ok(()) -} - -/// Backwards-compatible shim for callers that only have a workspace path. -/// -/// Delegates to [`discover_skills`] with the current user's home directory -/// so user-scope skills (`~/.openhuman/skills/`, `~/.agents/skills/`) are -/// surfaced for existing production callers (`agent::harness::session::builder`, -/// `channels::runtime::startup`). Previously this shim passed `None` for the -/// home directory, which silently dropped user-installed skills from the -/// main runtime path. -/// -/// Project-scope (workspace) skills still take precedence over user-scope -/// on name collisions. -pub fn load_skills(workspace_dir: &Path) -> Vec { - let trusted = is_workspace_trusted(workspace_dir); - let home = dirs::home_dir(); - discover_skills_inner(home.as_deref(), Some(workspace_dir), trusted) -} - -/// Discover skills from every supported location. -/// -/// * `home_dir` — user home (typically `dirs::home_dir()`), scanned for -/// `~/.openhuman/skills/` and `~/.agents/skills/`. -/// * `workspace_dir` — current workspace, scanned for project-scope paths. -/// * `trusted` — whether the caller has verified the project trust marker. -/// Project-scope skills are silently skipped when `false`. -/// -/// On name collisions, project-scope wins over user-scope and a warning is -/// attached to the retained skill. -pub fn discover_skills( - home_dir: Option<&Path>, - workspace_dir: Option<&Path>, - trusted: bool, -) -> Vec { - discover_skills_inner(home_dir, workspace_dir, trusted) -} - -/// Whether the workspace has opted into loading project-scope skills. -/// -/// Looks for `/.openhuman/trust`. The marker file's contents are -/// ignored — presence is sufficient. -pub fn is_workspace_trusted(workspace_dir: &Path) -> bool { - workspace_dir.join(".openhuman").join(TRUST_MARKER).exists() -} - -fn discover_skills_inner( - home_dir: Option<&Path>, - workspace_dir: Option<&Path>, - trusted: bool, -) -> Vec { - // Scan order matters for collision resolution: the last scope to register - // a name wins, so we scan user first, then project, then legacy. - let mut by_name: HashMap = HashMap::new(); - - if let Some(home) = home_dir { - for root in user_roots(home) { - absorb(&mut by_name, scan_root(&root, SkillScope::User)); - } - } - - if let Some(ws) = workspace_dir { - if trusted { - for root in project_roots(ws) { - absorb(&mut by_name, scan_root(&root, SkillScope::Project)); - } - } - // Legacy `/skills/` is always scanned so existing setups - // keep working without requiring users to move files or add the trust - // marker. Flagged with `legacy = true` so the UI can nudge migration. - absorb( - &mut by_name, - scan_root(&ws.join("skills"), SkillScope::Legacy), - ); - } - - let mut out: Vec = by_name.into_values().collect(); - out.sort_by(|a, b| a.name.cmp(&b.name)); - out -} - -fn user_roots(home: &Path) -> Vec { - vec![ - home.join(".openhuman").join("skills"), - home.join(".agents").join("skills"), - ] -} - -fn project_roots(workspace: &Path) -> Vec { - vec![ - workspace.join(".openhuman").join("skills"), - workspace.join(".agents").join("skills"), - ] -} - -fn absorb(by_name: &mut HashMap, incoming: Vec) { - for mut skill in incoming { - let key = skill.name.clone(); - if let Some(existing) = by_name.remove(&key) { - // Higher-precedence scope wins; lower loses and is dropped. - let (winner, loser) = if precedence(skill.scope) >= precedence(existing.scope) { - (&mut skill, existing) - } else { - // Put existing back; discard incoming. - let mut kept = existing; - kept.warnings.push(format!( - "name '{}' also declared in {:?} scope at {} (ignored)", - kept.name, - skill.scope, - skill - .location - .as_deref() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "".to_string()) - )); - by_name.insert(key, kept); - continue; - }; - winner.warnings.push(format!( - "shadowed {:?}-scope skill at {} with same name", - loser.scope, - loser - .location - .as_deref() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "".to_string()) - )); - } - by_name.insert(key, skill); - } -} - -fn precedence(scope: SkillScope) -> u8 { - match scope { - SkillScope::Legacy => 0, - SkillScope::User => 1, - SkillScope::Project => 2, - } -} - -fn scan_root(root: &Path, scope: SkillScope) -> Vec { - let entries = match std::fs::read_dir(root) { - Ok(entries) => entries, - Err(_) => return Vec::new(), - }; - - // `read_dir` order is unspecified. When two sibling directories declare - // the same logical `frontmatter.name` (which can differ from the folder - // name), cross-scope/same-scope deduplication downstream would otherwise - // pick a non-deterministic winner across runs. Sort by on-disk directory - // name for a stable, reproducible order. - let mut entries: Vec<_> = entries.flatten().collect(); - entries.sort_by_key(|entry| entry.file_name()); - - let mut out = Vec::new(); - for entry in entries { - // Use `file_type()` rather than `path.is_dir()` so a symlinked - // child cannot be loaded as a skill. `is_dir()` dereferences - // symlinks, which would re-open out-of-tree loading even though - // `walk_files` already rejects symlinks deeper in the resource - // walker. Skip both symlinks and non-directory entries here; if - // the `file_type()` call itself fails (rare — transient I/O), - // treat it as "not safe to traverse" and skip. - let Ok(file_type) = entry.file_type() else { - continue; - }; - if file_type.is_symlink() || !file_type.is_dir() { - continue; - } - let path = entry.path(); - let dir_name = entry.file_name().to_string_lossy().to_string(); - if dir_name.starts_with('.') { - continue; - } - if let Some(skill) = load_skill_dir(&path, &dir_name, scope) { - out.push(skill); - } - } - out -} - -fn load_skill_dir(dir: &Path, dir_name: &str, scope: SkillScope) -> Option { - let skill_md = dir.join(SKILL_MD); - let legacy_manifest = dir.join(SKILL_JSON); - - if skill_md.exists() { - return Some(load_from_skill_md(&skill_md, dir, dir_name, scope)); - } - if legacy_manifest.exists() { - return Some(load_from_legacy_manifest( - &legacy_manifest, - dir, - dir_name, - scope, - )); - } - None -} - -fn load_from_skill_md(skill_md: &Path, dir: &Path, dir_name: &str, scope: SkillScope) -> Skill { - let mut warnings = Vec::new(); - let (frontmatter, body) = match parse_skill_md(skill_md) { - Some((fm, body, parse_warnings)) => { - warnings.extend(parse_warnings); - (fm, body) - } - None => { - warnings.push(format!( - "could not parse {} — exposing directory as placeholder", - skill_md.display() - )); - (SkillFrontmatter::default(), String::new()) - } - }; - - let name = if frontmatter.name.trim().is_empty() { - warnings.push("frontmatter missing 'name'; using directory name".to_string()); - dir_name.to_string() - } else { - if frontmatter.name != dir_name { - warnings.push(format!( - "frontmatter name '{}' does not match directory '{}'", - frontmatter.name, dir_name - )); - } - if frontmatter.name.len() > MAX_NAME_LEN { - warnings.push(format!( - "frontmatter name is {} chars (max recommended: {})", - frontmatter.name.len(), - MAX_NAME_LEN - )); - } - frontmatter.name.clone() - }; - - let description = if frontmatter.description.trim().is_empty() { - warnings - .push("frontmatter missing 'description'; falling back to first body line".to_string()); - first_body_line(&body).unwrap_or_else(|| "No description provided".to_string()) - } else { - if frontmatter.description.len() > MAX_DESCRIPTION_LEN { - warnings.push(format!( - "description is {} chars (max recommended: {})", - frontmatter.description.len(), - MAX_DESCRIPTION_LEN - )); - } - frontmatter.description.clone() - }; - - let version = extract_version(&frontmatter, &mut warnings); - let author = extract_author(&frontmatter, &mut warnings); - let tags = extract_tags(&frontmatter, &mut warnings); - let tools = frontmatter.allowed_tools.clone(); - - Skill { - name, - dir_name: dir_name.to_string(), - description, - version, - author, - tags, - tools, - prompts: Vec::new(), - location: Some(skill_md.to_path_buf()), - frontmatter, - resources: inventory_resources(dir), - scope, - legacy: false, - warnings, - } -} - -fn load_from_legacy_manifest( - manifest_path: &Path, - dir: &Path, - dir_name: &str, - scope: SkillScope, -) -> Skill { - let mut warnings = vec![format!( - "skill uses legacy skill.json; migrate to SKILL.md frontmatter" - )]; - let parsed = std::fs::read_to_string(manifest_path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()); - - let manifest = parsed.unwrap_or_else(|| { - warnings.push(format!( - "could not parse {} as JSON; using directory name", - manifest_path.display() - )); - LegacySkillManifest { - name: dir_name.to_string(), - description: String::new(), - version: String::new(), - author: None, - tags: Vec::new(), - tools: Vec::new(), - prompts: Vec::new(), - } - }); - - let name = if manifest.name.trim().is_empty() { - dir_name.to_string() - } else { - manifest.name - }; - - // `load_from_legacy_manifest` is only called when SKILL.md is absent - // (see load_skill_dir), so there is no SKILL.md to fall back to here. - let description = if manifest.description.is_empty() { - "No description provided".to_string() - } else { - manifest.description - }; - - let location = Some(manifest_path.to_path_buf()); - - Skill { - name, - dir_name: dir_name.to_string(), - description, - version: manifest.version, - author: manifest.author, - tags: manifest.tags, - tools: manifest.tools, - prompts: manifest.prompts, - location, - frontmatter: SkillFrontmatter::default(), - resources: inventory_resources(dir), - scope, - legacy: true, - warnings, - } -} - -/// Split a `SKILL.md` file into parsed frontmatter and the remaining body. -/// -/// Accepts frontmatter delimited by leading `---` lines. Returns `None` when -/// the file cannot be read or the frontmatter block is unterminated. -/// -/// The third element of the tuple carries parse-level diagnostics — for now -/// just the YAML deserialisation error when frontmatter exists but is -/// malformed. Callers merge these into the skill's user-visible warnings so -/// the catalog surfaces the real cause instead of a generic "could not parse" -/// placeholder. -pub fn parse_skill_md(path: &Path) -> Option<(SkillFrontmatter, String, Vec)> { - let content = std::fs::read_to_string(path).ok()?; - parse_skill_md_str(&content) -} - -/// Content-only variant of [`parse_skill_md`] used when the SKILL.md has been -/// fetched over HTTPS (see [`install_skill_from_url`]) and has not yet landed -/// on disk. Returns `None` when the frontmatter block is opened with `---` but -/// never terminated — the same failure mode the file-based parser rejects. -pub fn parse_skill_md_str(content: &str) -> Option<(SkillFrontmatter, String, Vec)> { - let mut lines = content.lines(); - let first = lines.next()?; - if first.trim() != "---" { - // No frontmatter — treat whole file as body. - return Some((SkillFrontmatter::default(), content.to_string(), Vec::new())); - } - - let mut yaml = String::new(); - let mut terminated = false; - let mut body = String::new(); - for line in lines { - if line.trim() == "---" { - terminated = true; - continue; - } - if !terminated { - yaml.push_str(line); - yaml.push('\n'); - } else { - body.push_str(line); - body.push('\n'); - } - } - - if !terminated { - return None; - } - - let mut parse_warnings = Vec::new(); - let frontmatter = match serde_yaml::from_str::(&yaml) { - Ok(fm) => fm, - Err(err) => { - log::warn!("[skills] failed to parse frontmatter: {err}"); - parse_warnings.push(format!("frontmatter parse error: {err}")); - SkillFrontmatter::default() - } - }; - - Some((frontmatter, body, parse_warnings)) -} - -/// Shallow-scan a skill directory for bundled resources. -/// -/// Returns every file (relative to `dir`) under any of the conventional -/// resource subdirectories (`scripts/`, `references/`, `assets/`). Deeper -/// nesting is walked recursively. -pub fn inventory_resources(dir: &Path) -> Vec { - let mut out = Vec::new(); - for sub in RESOURCE_DIRS { - let root = dir.join(sub); - // `root.is_dir()` follows symlinks, so a `scripts -> /some/other/tree` - // symlink would still pass and `walk_files` would inventory the - // external tree. Use `symlink_metadata` for a non-dereferencing check - // and reject symlinked roots outright; `walk_files` already guards - // deeper symlinks inside the tree. - let meta = match std::fs::symlink_metadata(&root) { - Ok(m) => m, - Err(_) => continue, - }; - if meta.file_type().is_symlink() || !meta.is_dir() { - continue; - } - walk_files(&root, dir, &mut out); - } - out.sort(); - out -} - -/// Read a bundled skill resource as UTF-8 text, hardened against directory -/// traversal, symlink escape, and oversized payloads. -/// -/// `skill_id` identifies the skill by its discovered `name` — the same field -/// surfaced on [`Skill::name`]. The skill is resolved by running the standard -/// discovery pipeline (`dirs::home_dir()` + `workspace_dir`, honoring the -/// `.openhuman/trust` marker) and locating the matching entry; this keeps the -/// read scoped to legitimately installed skills and reuses all the symlink / -/// traversal hardening already baked into discovery. -/// -/// `relative_path` is resolved relative to the skill's on-disk directory -/// (the parent of its `SKILL.md` / `skill.json`). All of the following are -/// rejected with an error: -/// -/// * paths that canonicalize outside the skill root (traversal), -/// * paths whose final component or any intermediate component is a symlink -/// (link-follow escape), -/// * non-file targets (directories, sockets, fifos), -/// * files larger than [`MAX_SKILL_RESOURCE_BYTES`], -/// * non-UTF-8 byte contents (binary files must be surfaced some other way — -/// no lossy replacement). -/// -/// On success returns the file's contents as an owned `String`. -pub fn read_skill_resource( - workspace_dir: &Path, - skill_id: &str, - relative_path: &Path, -) -> Result { - tracing::debug!( - skill_id = %skill_id, - relative_path = %relative_path.display(), - workspace = %workspace_dir.display(), - "[skills] read_skill_resource: entry" - ); - - if skill_id.trim().is_empty() { - return Err("skill_id must not be empty".to_string()); - } - - let relative_str = relative_path.to_string_lossy(); - if relative_str.trim().is_empty() { - return Err("relative_path must not be empty".to_string()); - } - if relative_path.is_absolute() { - return Err("relative_path must be relative, not absolute".to_string()); - } - // Reject any component that is `..`, is empty, starts with `.`, or is the - // root. `..` is the obvious traversal vector; the others are defense in - // depth against unusual path inputs (e.g. `./`, `//foo`, Windows `C:`). - for component in relative_path.components() { - use std::path::Component; - match component { - Component::Normal(_) => {} - Component::ParentDir => { - return Err("relative_path must not contain '..' components".to_string()); - } - Component::CurDir | Component::RootDir | Component::Prefix(_) => { - return Err("relative_path must be a plain relative path".to_string()); - } - } - } - - // Resolve the skill by running the standard discovery pipeline. We reuse - // `load_skills` (which honors both user and workspace roots plus the - // trust marker) so the resource read is scoped to the exact same set of - // skills the UI would already have shown the user. - let skills = load_skills(workspace_dir); - let skill = skills - .into_iter() - .find(|s| s.name == skill_id) - .ok_or_else(|| format!("skill '{skill_id}' not found"))?; - let skill_root = skill - .location - .as_deref() - .and_then(|p| p.parent()) - .ok_or_else(|| format!("skill '{skill_id}' has no on-disk location"))? - .to_path_buf(); - - // Canonicalize the root first. The root must itself be a real directory - // on disk (not a symlink). Reject early if this fails. - let canonical_root = std::fs::canonicalize(&skill_root).map_err(|e| { - format!( - "failed to canonicalize skill root {}: {e}", - skill_root.display() - ) - })?; - - let requested = canonical_root.join(relative_path); - - // Pre-check the immediate target with `symlink_metadata` so we catch - // symlinked leaves before `canonicalize` silently follows them. - let leaf_meta = std::fs::symlink_metadata(&requested) - .map_err(|e| format!("failed to stat resource {}: {e}", requested.display()))?; - if leaf_meta.file_type().is_symlink() { - return Err("resource path is a symlink".to_string()); - } - if !leaf_meta.is_file() { - return Err("resource path is not a regular file".to_string()); - } - - // Size gate — check via metadata before reading so we never allocate the - // buffer for an oversized file. - let size = leaf_meta.len(); - if size > MAX_SKILL_RESOURCE_BYTES { - return Err(format!( - "resource file is {size} bytes, exceeds limit of {MAX_SKILL_RESOURCE_BYTES}" - )); - } - - // Canonicalize the full path and verify it stays within the skill root. - // This catches any symlink reachable via an intermediate path component - // that was created after our initial checks (race-ish, but the - // `is_symlink` check above makes the obvious attack infeasible). - let canonical_requested = std::fs::canonicalize(&requested).map_err(|e| { - format!( - "failed to canonicalize resource {}: {e}", - requested.display() - ) - })?; - if !canonical_requested.starts_with(&canonical_root) { - return Err(format!( - "resource path escapes skill root: {}", - canonical_requested.display() - )); - } - - // Read the bytes and enforce strict UTF-8 (no lossy replacement — we - // would rather refuse a binary file than silently mangle it). - let bytes = std::fs::read(&canonical_requested).map_err(|e| { - format!( - "failed to read resource {}: {e}", - canonical_requested.display() - ) - })?; - let content = std::str::from_utf8(&bytes) - .map_err(|e| format!("resource is not valid UTF-8 text: {e}"))? - .to_string(); - - tracing::debug!( - skill_id = %skill_id, - bytes = bytes.len(), - "[skills] read_skill_resource: success" - ); - - Ok(content) -} - -fn walk_files(current: &Path, base: &Path, out: &mut Vec) { - let entries = match std::fs::read_dir(current) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - // Use `file_type()` — not `is_dir()` / `is_file()` — so we can detect and - // skip symlinks before traversing. `is_dir()`/`is_file()` follow symlinks - // and would cause unbounded recursion on a cycle (e.g. `resources/self -> - // resources/`) or silent leakage outside the skill directory when a - // symlink points at `/`, `/etc`, or another skill's tree. - let Ok(file_type) = entry.file_type() else { - continue; - }; - if file_type.is_symlink() { - continue; - } - let path = entry.path(); - if file_type.is_dir() { - walk_files(&path, base, out); - } else if file_type.is_file() { - if let Ok(rel) = path.strip_prefix(base) { - out.push(rel.to_path_buf()); - } - } - } -} - -fn first_body_line(body: &str) -> Option { - for line in body.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - return Some(trimmed.to_string()); - } - None -} - -/// Input for [`create_skill`]. Mirrors the `skills.create` JSON-RPC payload. -#[derive(Debug, Clone, Deserialize, Default)] -pub struct CreateSkillParams { - /// Human-readable name — slugified into the on-disk folder. - pub name: String, - /// One-line description written into the frontmatter. - pub description: String, - /// Where to install: `user`, `project`, or `legacy`. Defaults to `user`. - #[serde(default)] - pub scope: SkillScope, - /// Optional SPDX license (written to frontmatter `license`). - #[serde(default)] - pub license: Option, - /// Optional author name (written under frontmatter `metadata.author`). - #[serde(default)] - pub author: Option, - /// Optional tags (written under frontmatter `metadata.tags`). - #[serde(default)] - pub tags: Vec, - /// Optional tool hints (written to frontmatter `allowed-tools`). - #[serde(default, rename = "allowed-tools", alias = "allowed_tools")] - pub allowed_tools: Vec, -} - -/// Scaffold a new SKILL.md-based skill on disk. -/// -/// Writes `//SKILL.md` with frontmatter derived from -/// `params` and creates empty `scripts/`, `references/`, `assets/` subdirs -/// so the author has somewhere to drop bundled resources. -/// -/// Scope resolution: -/// * [`SkillScope::User`] → `~/.openhuman/skills/` -/// * [`SkillScope::Project`] → `/.openhuman/skills/`. Requires the -/// trust marker at `/.openhuman/trust` to be present; otherwise -/// rejected with an error. -/// * [`SkillScope::Legacy`] → rejected. Callers must pick one of the -/// above; the legacy `/skills/` layout is read-only going -/// forward. -/// -/// Name hardening: -/// * Slug is derived from `params.name` (lowercased, `[a-z0-9-]` only, -/// non-alphanumeric runs collapsed to a single `-`). -/// * Empty / non-alphanumeric-only names are rejected. -/// * Slug is length-bounded by [`MAX_NAME_LEN`]. -/// * The resolved `/` path is canonicalized and verified -/// to stay inside the canonical scope root (same `starts_with` guard used -/// by [`read_skill_resource`]) to defeat `..` or absolute-path inputs. -/// * Collisions with an existing directory are rejected outright — this -/// function never overwrites. -/// -/// On success the freshly created skill is re-discovered through the standard -/// pipeline and returned so callers can drop it straight into the UI list. -pub fn create_skill(workspace_dir: &Path, params: CreateSkillParams) -> Result { - let home = dirs::home_dir(); - create_skill_inner(home.as_deref(), workspace_dir, params) -} - -fn create_skill_inner( - home_dir: Option<&Path>, - workspace_dir: &Path, - params: CreateSkillParams, -) -> Result { - tracing::debug!( - name = %params.name, - scope = ?params.scope, - workspace = %workspace_dir.display(), - "[skills] create_skill: entry" - ); - - let display_name = params.name.trim(); - if display_name.is_empty() { - return Err("name must not be empty".to_string()); - } - if display_name.len() > MAX_NAME_LEN { - return Err(format!("name exceeds max {MAX_NAME_LEN} chars")); - } - - let description = params.description.trim(); - if description.is_empty() { - return Err("description must not be empty".to_string()); - } - if description.len() > MAX_DESCRIPTION_LEN { - return Err(format!( - "description exceeds max {MAX_DESCRIPTION_LEN} chars" - )); - } - - let slug = slugify_skill_name(display_name)?; - - let scope_root = match params.scope { - SkillScope::User => { - let home = - home_dir.ok_or_else(|| "could not resolve user home directory".to_string())?; - home.join(".openhuman").join("skills") - } - SkillScope::Project => { - if !is_workspace_trusted(workspace_dir) { - return Err(format!( - "workspace {} is not trusted; create {}/.openhuman/trust to enable project-scope skills", - workspace_dir.display(), - workspace_dir.display(), - )); - } - workspace_dir.join(".openhuman").join("skills") - } - SkillScope::Legacy => { - return Err( - "cannot create skill in legacy scope; choose 'user' or 'project'".to_string(), - ); - } - }; - - std::fs::create_dir_all(&scope_root) - .map_err(|e| format!("failed to create skills root {}: {e}", scope_root.display()))?; - - let canonical_root = std::fs::canonicalize(&scope_root).map_err(|e| { - format!( - "failed to canonicalize skills root {}: {e}", - scope_root.display() - ) - })?; - - let skill_dir = canonical_root.join(&slug); - if !skill_dir.starts_with(&canonical_root) { - return Err(format!( - "resolved skill dir {} escapes scope root {}", - skill_dir.display(), - canonical_root.display(), - )); - } - - if skill_dir.exists() { - return Err(format!( - "skill '{slug}' already exists at {}", - skill_dir.display() - )); - } - - std::fs::create_dir_all(&skill_dir) - .map_err(|e| format!("failed to create skill dir {}: {e}", skill_dir.display()))?; - - let skill_md_path = skill_dir.join(SKILL_MD); - let skill_md = render_skill_md( - &slug, - description, - params.license.as_deref(), - params.author.as_deref(), - ¶ms.tags, - ¶ms.allowed_tools, - ); - std::fs::write(&skill_md_path, skill_md) - .map_err(|e| format!("failed to write {}: {e}", skill_md_path.display()))?; - - for sub in RESOURCE_DIRS { - let sub_path = skill_dir.join(sub); - std::fs::create_dir_all(&sub_path) - .map_err(|e| format!("failed to create {}: {e}", sub_path.display()))?; - } - - tracing::info!( - slug = %slug, - scope = ?params.scope, - location = %skill_md_path.display(), - "[skills] create_skill: wrote SKILL.md" - ); - - let trusted = is_workspace_trusted(workspace_dir); - let created = discover_skills_inner(home_dir, Some(workspace_dir), trusted) - .into_iter() - .find(|s| s.name == slug) - .ok_or_else(|| format!("created skill '{slug}' but failed to re-discover"))?; - Ok(created) -} - -/// Convert a human-readable skill name to a filesystem-safe slug. -/// -/// Rules: -/// * ASCII alphanumeric characters are lowercased and kept. -/// * Whitespace, `-`, and `_` collapse to a single `-`. -/// * Any other character is dropped. -/// * Leading / trailing `-` are trimmed. -/// * The empty slug (i.e. the name had no `[a-z0-9]` characters) is rejected. -fn slugify_skill_name(name: &str) -> Result { - let mut out = String::new(); - let mut prev_hyphen = true; - for ch in name.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_lowercase()); - prev_hyphen = false; - } else if (ch == '-' || ch == '_' || ch.is_whitespace()) && !prev_hyphen { - out.push('-'); - prev_hyphen = true; - } - } - while out.ends_with('-') { - out.pop(); - } - if out.is_empty() { - return Err(format!( - "name '{name}' has no alphanumeric characters; cannot derive slug" - )); - } - if out.len() > MAX_NAME_LEN { - return Err(format!("slug '{out}' exceeds max {MAX_NAME_LEN} chars")); - } - Ok(out) -} - -/// Render a minimal SKILL.md body for a freshly scaffolded skill. -fn render_skill_md( - slug: &str, - description: &str, - license: Option<&str>, - author: Option<&str>, - tags: &[String], - allowed_tools: &[String], -) -> String { - let mut out = String::new(); - out.push_str("---\n"); - out.push_str(&format!("name: {slug}\n")); - out.push_str(&format!("description: {}\n", yaml_scalar(description))); - if let Some(v) = license { - out.push_str(&format!("license: {}\n", yaml_scalar(v))); - } - let has_metadata = author.is_some() || !tags.is_empty(); - if has_metadata { - out.push_str("metadata:\n"); - if let Some(v) = author { - out.push_str(&format!(" author: {}\n", yaml_scalar(v))); - } - if !tags.is_empty() { - out.push_str(" tags:\n"); - for t in tags { - out.push_str(&format!(" - {}\n", yaml_scalar(t))); - } - } - } - if !allowed_tools.is_empty() { - out.push_str("allowed-tools:\n"); - for t in allowed_tools { - out.push_str(&format!(" - {}\n", yaml_scalar(t))); - } - } - out.push_str("---\n\n"); - out.push_str(&format!("# {slug}\n\n")); - out.push_str(description); - if !description.ends_with('\n') { - out.push('\n'); - } - out.push_str("\n## Instructions\n\n"); - out.push_str("_Describe when and how this skill should be used._\n"); - out -} - -/// Best-effort YAML scalar encoder: pass plain-safe strings through, -/// double-quote anything with structure / whitespace / control chars. -fn yaml_scalar(s: &str) -> String { - let needs_quote = s.is_empty() - || s.chars().any(|c| { - matches!( - c, - ':' | '#' - | '\'' - | '"' - | '\n' - | '\r' - | '\t' - | '[' - | ']' - | '{' - | '}' - | ',' - | '&' - | '*' - | '!' - | '|' - | '>' - | '%' - | '@' - | '`' - ) - }) - || s.starts_with(|c: char| c.is_ascii_whitespace() || c == '-' || c == '?') - || s.ends_with(|c: char| c.is_ascii_whitespace()); - if !needs_quote { - return s.to_string(); - } - let escaped = s - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', "\\n") - .replace('\r', "\\r") - .replace('\t', "\\t"); - format!("\"{escaped}\"") -} - -/// Default wall-clock budget for the SKILL.md fetch. -pub const DEFAULT_INSTALL_TIMEOUT_SECS: u64 = 60; -/// Hard ceiling callers can request via `timeout_secs`. -pub const MAX_INSTALL_TIMEOUT_SECS: u64 = 600; -/// Upper bound on raw URL length accepted by [`validate_install_url`]. -pub const MAX_INSTALL_URL_LEN: usize = 2048; -/// Upper bound on the fetched SKILL.md body. Single-file skills rarely exceed -/// a few KB; the 1 MiB cap here is a defensive limit against a hostile or -/// misconfigured host streaming an unbounded response into memory. -pub const MAX_SKILL_MD_BYTES: usize = 1024 * 1024; - -/// Input for [`install_skill_from_url`]. Mirrors the `skills.install_from_url` -/// JSON-RPC payload. -#[derive(Debug, Clone, Deserialize)] -pub struct InstallSkillFromUrlParams { - /// Remote SKILL.md URL. Must be `https://`, resolve to a non-private host - /// (see [`validate_install_url`]), and point at a `.md` file after - /// github.com `/blob/` normalization. - pub url: String, - /// Optional wall-clock budget override, in seconds. Defaults to - /// [`DEFAULT_INSTALL_TIMEOUT_SECS`] and is capped at - /// [`MAX_INSTALL_TIMEOUT_SECS`]. - #[serde(default)] - pub timeout_secs: Option, -} - -/// Outcome of a successful install. `new_skills` is the set of skill slugs -/// that appeared in the catalog since the start of the call (post-discovery -/// minus pre-discovery). -#[derive(Debug, Clone, Serialize)] -pub struct InstallSkillFromUrlOutcome { - /// The URL the caller submitted, trimmed. - pub url: String, - /// Human-readable install log — typically `Fetched N bytes from \n - /// Installed to `. Repurposed from the old npx stdout field so the - /// UI success panel keeps the same `
` layout. - pub stdout: String, - /// Non-fatal warnings surfaced during parse (e.g. deprecated top-level - /// `version`/`author`/`tags`). Empty on the happy path. Repurposed from - /// the old npx stderr field. - pub stderr: String, - /// Slugs that appeared in the workspace skill catalog as a result of the - /// install. Usually one, empty only when the SKILL.md could not be - /// enumerated by discovery (rare — indicates workspace trust mismatch). - pub new_skills: Vec, -} - -/// Install a skill by fetching its `SKILL.md` directly over HTTPS and writing -/// it to `/.openhuman/skills//SKILL.md`. -/// -/// Design rationale: openhuman's skill discovery scans -/// `/.openhuman/skills/` (plus `~/.openhuman/skills/` and legacy -/// paths), **not** the per-agent subdirectories that the vercel-labs `skills` -/// CLI writes to (`./claude-code/skills/`, `./cursor/skills/`, â€Ļ). The CLI's -/// agent ecosystem is incompatible with openhuman's skill layout, so we fetch -/// the SKILL.md file directly and install it into a layout discovery sees. -/// -/// Validation applied before any network I/O: -/// * URL length, scheme (`https` only), and host safety via -/// [`validate_install_url`] — rejects loopback, private, link-local, -/// multicast, shared-address ranges, `localhost`, and `.local` / `.localhost` -/// mDNS-style hostnames. -/// * `github.com///blob//

` is rewritten to the raw -/// `raw.githubusercontent.com////

` equivalent so humans can -/// paste the URL they see in the browser. -/// * The path must end in `.md` (case-insensitive). Repo/tree URLs and -/// tarballs are rejected with `unsupported url form:`. -/// * `timeout_secs` is clamped to [`MAX_INSTALL_TIMEOUT_SECS`]. -/// -/// Runtime: -/// * Body size is capped by [`MAX_SKILL_MD_BYTES`] (1 MiB). The advertised -/// `Content-Length` is checked up front; the buffered body length is -/// checked again after the download as defense against a lying header. -/// * Frontmatter is validated — `name` and `description` are required per -/// the agentskills.io spec. -/// * The slug is derived from `metadata.id` when present, otherwise the -/// sanitized `name` field. Collision with an existing directory is fatal -/// (no silent overwrite). -/// * Write is atomic: `SKILL.md.tmp` in the target dir, then `rename` on -/// success. -/// -/// On success the full post-install skills catalog is re-discovered and the -/// outcome includes the list of skill slugs that appeared since the start of -/// the call. -pub async fn install_skill_from_url( - workspace_dir: &Path, - params: InstallSkillFromUrlParams, -) -> Result { - let raw_url = params.url.trim().to_string(); - validate_install_url(&raw_url)?; - - let timeout_secs = params - .timeout_secs - .unwrap_or(DEFAULT_INSTALL_TIMEOUT_SECS) - .clamp(1, MAX_INSTALL_TIMEOUT_SECS); - - let fetch_url = normalize_install_url(&raw_url)?; - - // Second-layer SSRF guard: a public-looking hostname can still resolve - // to a loopback / private / link-local address (DNS-to-private-IP). We - // resolve the host up-front and reject if any returned IP is private. - // Known caveat: this does not fully prevent DNS rebinding — reqwest's - // resolver may see different answers than ours. Closing that gap requires - // pinning a `SocketAddr` and passing it to reqwest via a custom resolver, - // tracked separately. - validate_resolved_host(&fetch_url).await?; - - tracing::debug!( - raw_url = %raw_url, - fetch_url = %fetch_url, - workspace = %workspace_dir.display(), - timeout_secs = timeout_secs, - "[skills] install_skill_from_url: entry" - ); - - let home = dirs::home_dir(); - let trusted_before = is_workspace_trusted(workspace_dir); - let before: std::collections::HashSet = - discover_skills_inner(home.as_deref(), Some(workspace_dir), trusted_before) - .into_iter() - .map(|s| s.name) - .collect(); - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(timeout_secs)) - .build() - .map_err(|e| format!("fetch failed: build http client: {e}"))?; - - tracing::info!( - fetch_url = %fetch_url, - "[skills] install_skill_from_url: fetching SKILL.md" - ); - - let response = match client.get(&fetch_url).send().await { - Ok(resp) => resp, - Err(e) => { - if e.is_timeout() { - return Err(format!("fetch timed out after {timeout_secs}s")); - } - return Err(format!("fetch failed: {e}")); - } - }; - - let status = response.status(); - if !status.is_success() { - return Err(format!( - "fetch failed: {fetch_url} returned status {}", - status.as_u16() - )); - } - - if let Some(len) = response.content_length() { - if len > MAX_SKILL_MD_BYTES as u64 { - return Err(format!( - "fetch too large: {} bytes exceeds {MAX_SKILL_MD_BYTES} limit", - len - )); - } - } - - let bytes = match response.bytes().await { - Ok(b) => b, - Err(e) => { - if e.is_timeout() { - return Err(format!("fetch timed out after {timeout_secs}s")); - } - return Err(format!("fetch failed: reading body: {e}")); - } - }; - - if bytes.len() > MAX_SKILL_MD_BYTES { - return Err(format!( - "fetch too large: {} bytes exceeds {MAX_SKILL_MD_BYTES} limit", - bytes.len() - )); - } - - let content = String::from_utf8(bytes.to_vec()) - .map_err(|e| format!("invalid SKILL.md: body is not valid utf-8: {e}"))?; - - let (frontmatter, _body, parse_warnings) = parse_skill_md_str(&content).ok_or_else(|| { - "invalid SKILL.md: frontmatter block opened with `---` but never terminated".to_string() - })?; - - if frontmatter.name.trim().is_empty() { - return Err("invalid SKILL.md: missing required field 'name'".to_string()); - } - if frontmatter.description.trim().is_empty() { - return Err("invalid SKILL.md: missing required field 'description'".to_string()); - } - - let slug = derive_install_slug(&frontmatter)?; - - // Install to user scope (`~/.openhuman/skills/`), which `discover_skills` - // scans unconditionally. Project scope (`/.openhuman/skills/`) is gated on - // a `/.openhuman/trust` marker and would render the install invisible to the - // skills list until the user opts the workspace into trust. - let skills_root = home - .as_deref() - .ok_or_else(|| "write failed: unable to resolve home directory".to_string())? - .join(".openhuman") - .join("skills"); - let target_dir = skills_root.join(&slug); - if target_dir.exists() { - return Err(format!( - "skill already installed as {slug:?} at {}", - target_dir.display() - )); - } - - std::fs::create_dir_all(&target_dir).map_err(|e| { - format!( - "write failed: create directory {}: {e}", - target_dir.display() - ) - })?; - - let target_file = target_dir.join(SKILL_MD); - let temp_file = target_dir.join("SKILL.md.tmp"); - - // Roll the partial install back if either filesystem op fails so the - // next retry isn't blocked by a leftover empty directory. Cleanup is - // best-effort — if it fails, we surface the original write error. - let write_result: Result<(), String> = std::fs::write(&temp_file, &content) - .map_err(|e| format!("write failed: {}: {e}", temp_file.display())) - .and_then(|_| { - std::fs::rename(&temp_file, &target_file) - .map_err(|e| format!("write failed: rename {}: {e}", target_file.display())) - }); - - if let Err(e) = write_result { - let _ = std::fs::remove_file(&temp_file); - if let Err(rm_err) = std::fs::remove_dir(&target_dir) { - tracing::warn!( - target_dir = %target_dir.display(), - error = %rm_err, - "[skills] install_skill_from_url: rollback remove_dir failed (non-fatal)" - ); - } else { - tracing::warn!( - target_dir = %target_dir.display(), - "[skills] install_skill_from_url: rolled back partial install after write failure" - ); - } - return Err(e); - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o644); - if let Err(e) = std::fs::set_permissions(&target_file, perms) { - tracing::warn!( - target = %target_file.display(), - error = %e, - "[skills] install_skill_from_url: chmod 0644 failed (non-fatal)" - ); - } - } - - let trusted_after = is_workspace_trusted(workspace_dir); - let after = discover_skills_inner(home.as_deref(), Some(workspace_dir), trusted_after); - let new_skills: Vec = after - .into_iter() - .map(|s| s.name) - .filter(|name| !before.contains(name)) - .collect(); - - tracing::info!( - raw_url = %raw_url, - fetch_url = %fetch_url, - slug = %slug, - bytes = content.len(), - new_count = new_skills.len(), - "[skills] install_skill_from_url: completed" - ); - - let stdout = format!( - "Fetched {} bytes from {fetch_url}\nInstalled to {}", - content.len(), - target_file.display() - ); - let stderr = parse_warnings.join("\n"); - - Ok(InstallSkillFromUrlOutcome { - url: raw_url, - stdout, - stderr, - new_skills, - }) -} - -/// Rewrite `github.com///blob//` into its raw counterpart -/// so a URL copied from a browser's GitHub page resolves to the file body -/// instead of the HTML wrapper. Any other host is returned unchanged. -/// -/// Also enforces that the final path ends in `.md` (case-insensitive). Tree, -/// commit, and whole-repo URLs are rejected here — they require a -/// fundamentally different install path (recursive fetch / tarball) that is -/// out of scope for single-file SKILL.md installs. -fn normalize_install_url(raw: &str) -> Result { - let parsed = - url::Url::parse(raw).map_err(|e| format!("unsupported url form: parse {raw:?}: {e}"))?; - let host = parsed.host_str().unwrap_or("").to_ascii_lowercase(); - - let normalized = if host == "github.com" { - let segments: Vec<&str> = parsed - .path_segments() - .map(|it| it.collect()) - .unwrap_or_default(); - if segments.len() >= 5 && segments[2] == "blob" { - let owner = segments[0]; - let repo = segments[1]; - let branch = segments[3]; - let rest = segments[4..].join("/"); - format!("https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{rest}") - } else if segments.len() >= 3 && (segments[2] == "tree" || segments[2] == "raw") { - return Err(format!( - "unsupported url form: only direct SKILL.md links are supported, got {raw:?} (tree/dir URLs are not yet supported)" - )); - } else if segments.len() <= 2 { - return Err(format!( - "unsupported url form: only direct SKILL.md links are supported, got {raw:?} (whole-repo URLs are not yet supported)" - )); - } else { - raw.to_string() - } - } else { - raw.to_string() - }; - - let check = url::Url::parse(&normalized) - .map_err(|e| format!("unsupported url form: parse normalized {normalized:?}: {e}"))?; - let path_lower = check.path().to_ascii_lowercase(); - if !path_lower.ends_with(".md") { - return Err(format!( - "unsupported url form: path must end in .md, got {normalized:?}" - )); - } - - Ok(normalized) -} - -/// Derive the install directory slug from the SKILL.md frontmatter. -/// -/// Prefers `metadata.id` (the spec-aligned identifier) when present. Falls -/// back to a sanitized form of `name`: -/// * lowercase ASCII -/// * non-alphanumeric runs collapsed to a single `-` -/// * leading/trailing `-` trimmed -/// -/// Rejects the empty string and paths that would escape the skills root -/// (`..`, `/`, `\`). Max length is [`MAX_NAME_LEN`]. -fn derive_install_slug(fm: &SkillFrontmatter) -> Result { - let candidate = fm - .metadata - .get("id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| fm.name.clone()); - - let mut out = String::with_capacity(candidate.len()); - let mut last_dash = false; - for ch in candidate.chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_lowercase()); - last_dash = false; - } else if !last_dash && !out.is_empty() { - out.push('-'); - last_dash = true; - } - } - while out.ends_with('-') { - out.pop(); - } - - if out.is_empty() { - return Err( - "invalid SKILL.md: cannot derive slug from empty name/id — set a value in frontmatter" - .to_string(), - ); - } - if out.len() > MAX_NAME_LEN { - return Err(format!( - "invalid SKILL.md: derived slug {out:?} exceeds {MAX_NAME_LEN} chars" - )); - } - if out.contains("..") || out.contains('/') || out.contains('\\') { - return Err(format!( - "invalid SKILL.md: derived slug {out:?} contains forbidden path components" - )); - } - - Ok(out) -} - -/// Validate a remote skill install URL. Returns `Ok(())` when the URL is -/// well-formed, uses `https`, and points at a public host. -/// -/// Rejects: -/// * empty string or > [`MAX_INSTALL_URL_LEN`] bytes -/// * non-`https` schemes (including `http`, `ftp`, `file`, `git+ssh`) -/// * missing or empty host -/// * `localhost`, `*.localhost`, `*.local` -/// * IPv4 literals in loopback (127.0.0.0/8), private (10/8, 172.16/12, -/// 192.168/16), link-local (169.254/16), shared-address (100.64/10), -/// multicast, broadcast, or unspecified (0.0.0.0) ranges -/// * IPv6 literals in loopback (::1), unspecified (::), unique-local -/// (fc00::/7), link-local (fe80::/10), or multicast (ff00::/8) -pub fn validate_install_url(raw: &str) -> Result<(), String> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err("url must not be empty".to_string()); - } - if trimmed.len() > MAX_INSTALL_URL_LEN { - return Err(format!( - "url exceeds max {MAX_INSTALL_URL_LEN} chars (got {})", - trimmed.len() - )); - } - let parsed = url::Url::parse(trimmed).map_err(|e| format!("invalid url {trimmed:?}: {e}"))?; - if parsed.scheme() != "https" { - return Err(format!( - "url scheme {:?} not allowed; https only", - parsed.scheme() - )); - } - let host = parsed - .host_str() - .ok_or_else(|| format!("url {trimmed:?} has no host"))?; - if host.is_empty() { - return Err(format!("url {trimmed:?} has empty host")); - } - if is_blocked_install_host(host) { - return Err(format!( - "host {host:?} not allowed (loopback/private/link-local/multicast)" - )); - } - Ok(()) -} - -/// Resolve the host in the given URL and reject if any returned IP falls in -/// loopback / private / link-local / multicast / unspecified ranges. -/// -/// Covers the DNS-to-private-IP SSRF vector: a public-looking hostname can -/// still resolve to 127.0.0.1 / 169.254.x / fc00::/7 etc., which -/// [`validate_install_url`] alone cannot detect because it only inspects -/// literal IP hosts. -/// -/// Caveat: does **not** close the DNS-rebinding gap. `reqwest` performs its -/// own DNS lookup on the GET below, and a rebinding server can answer the -/// check with a public IP and answer reqwest with a private one. Full -/// mitigation requires resolving to a `SocketAddr` here and passing it to -/// reqwest via a custom resolver that only honours the pinned address. -pub async fn validate_resolved_host(raw_url: &str) -> Result<(), String> { - let parsed = url::Url::parse(raw_url) - .map_err(|e| format!("invalid url {raw_url:?} during DNS guard: {e}"))?; - let host = parsed - .host_str() - .ok_or_else(|| format!("url {raw_url:?} has no host (DNS guard)"))?; - // `tokio::net::lookup_host` wants "host:port". Default https → 443. - let port = parsed.port_or_known_default().unwrap_or(443); - // IPv6 literal hosts come back bracketed from `url::Url`; `lookup_host` - // needs the bracketed form for IPv6 to parse correctly. - let lookup_target = if parsed - .host() - .map(|h| matches!(h, url::Host::Ipv6(_))) - .unwrap_or(false) - { - format!("[{host}]:{port}") - } else { - format!("{host}:{port}") - }; - - tracing::debug!( - host = %host, - port = port, - "[skills] validate_resolved_host: resolving" - ); - - let mut addrs = tokio::net::lookup_host(&lookup_target) - .await - .map_err(|e| format!("dns lookup failed for {host:?}: {e}"))? - .peekable(); - if addrs.peek().is_none() { - return Err(format!("host {host:?} resolved to no IP addresses")); - } - for addr in addrs { - let ip = addr.ip(); - match ip { - std::net::IpAddr::V4(v4) => { - if is_private_v4(&v4) { - tracing::warn!( - host = %host, - resolved = %v4, - "[skills] validate_resolved_host: rejected private IPv4" - ); - return Err(format!( - "host {host:?} resolved to non-public IPv4 {v4} (loopback/private/link-local)" - )); - } - } - std::net::IpAddr::V6(v6) => { - if is_private_v6(&v6) { - tracing::warn!( - host = %host, - resolved = %v6, - "[skills] validate_resolved_host: rejected private IPv6" - ); - return Err(format!( - "host {host:?} resolved to non-public IPv6 {v6} (loopback/ula/link-local)" - )); - } - } - } - } - Ok(()) -} - -fn is_blocked_install_host(host: &str) -> bool { - let lower = host.to_ascii_lowercase(); - // url::Url::host_str returns IPv6 literals wrapped in brackets (e.g. "[::1]"). - // Strip them before attempting Ipv6Addr parse. - let stripped = lower - .strip_prefix('[') - .and_then(|s| s.strip_suffix(']')) - .unwrap_or(&lower); - if stripped == "localhost" || stripped.ends_with(".localhost") || stripped.ends_with(".local") { - return true; - } - if let Ok(v4) = stripped.parse::() { - return is_private_v4(&v4); - } - if let Ok(v6) = stripped.parse::() { - return is_private_v6(&v6); - } - false -} - -fn is_private_v4(ip: &Ipv4Addr) -> bool { - if ip.is_private() - || ip.is_loopback() - || ip.is_link_local() - || ip.is_broadcast() - || ip.is_unspecified() - || ip.is_multicast() - { - return true; - } - let [a, b, _, _] = ip.octets(); - // 100.64.0.0/10 shared address (CGN) - if a == 100 && (64..=127).contains(&b) { - return true; - } - // 0.0.0.0/8 - if a == 0 { - return true; - } - false -} - -fn is_private_v6(ip: &Ipv6Addr) -> bool { - if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() { - return true; - } - let first = ip.segments()[0]; - // fc00::/7 unique-local - if (first & 0xfe00) == 0xfc00 { - return true; - } - // fe80::/10 link-local - if (first & 0xffc0) == 0xfe80 { - return true; - } - false -} - -/// Input for [`uninstall_skill`]. Mirrors the `skills.uninstall` JSON-RPC -/// payload. -#[derive(Debug, Clone, Deserialize)] -pub struct UninstallSkillParams { - /// On-disk slug of the installed skill — the directory name under - /// `~/.openhuman/skills//`. This is `SkillSummary.id` on the wire, - /// which may diverge from the frontmatter display name exposed as - /// `Skill.name` when a skill's `SKILL.md` declares a name that does not - /// match its directory. Callers must send the slug (`SkillSummary.id`), - /// not the display name, or the RPC will return "not installed". - /// - /// Retained as `name` for wire-format back-compat with pre-existing - /// clients; the field's semantics are slug-only. - pub name: String, -} - -/// Outcome of a successful uninstall. -#[derive(Debug, Clone, Serialize)] -pub struct UninstallSkillOutcome { - /// The normalised slug that was removed. - pub name: String, - /// Absolute on-disk path that was deleted (post-canonicalisation). Useful - /// for user-visible confirmations and for logs. - pub removed_path: String, - /// Scope the uninstall applied to. Always `User` today. - pub scope: SkillScope, -} - -/// Remove an installed user-scope SKILL.md skill from `~/.openhuman/skills/`. -/// -/// Only **user-scope** uninstalls are supported: -/// -/// * Project-scope skills live inside the workspace — the user manages them -/// via git / editor / filesystem directly. Exposing a destructive delete -/// over RPC invites foot-guns, since a rogue skill's tool could uninstall -/// other project skills. -/// * Legacy `/skills/` skills are treated as read-only. They predate the -/// install flow and we don't want to blow away content the user may have -/// hand-authored. -/// -/// Resolution is defensive: -/// 1. `skills_root = /.openhuman/skills`, canonicalised. -/// 2. `candidate = skills_root.join(&name)`, canonicalised. -/// 3. Assert `candidate.starts_with(skills_root)` after canonicalisation — -/// blocks `..` traversal and symlink escapes. -/// 4. Refuse anything that is not a plain directory (no symlinks). -/// 5. Require `SKILL.md` to be present — we only remove things that look -/// like skills we installed, not arbitrary dirs the user dropped in. -/// 6. `fs::remove_dir_all`. -/// -/// `home_dir_override` is threaded through for tests. Production callers -/// pass `None`, which falls back to [`dirs::home_dir`]. -pub fn uninstall_skill( - params: UninstallSkillParams, - home_dir_override: Option<&Path>, -) -> Result { - let trimmed = params.name.trim().to_string(); - if trimmed.is_empty() { - return Err("skill name is required".to_string()); - } - if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { - log::warn!( - "[skills] uninstall_skill: rejected name with path separators name={:?}", - trimmed - ); - return Err(format!( - "skill name '{trimmed}' must not contain path separators" - )); - } - if trimmed.len() > MAX_NAME_LEN { - return Err(format!( - "skill name is {} chars (max {MAX_NAME_LEN})", - trimmed.len() - )); - } - - let home = match home_dir_override - .map(|p| p.to_path_buf()) - .or_else(dirs::home_dir) - { - Some(h) => h, - None => return Err("could not resolve user home directory".to_string()), - }; - - let skills_root = home.join(".openhuman").join("skills"); - if !skills_root.exists() { - return Err(format!( - "no user skills directory at {}", - skills_root.display() - )); - } - - // Refuse a symlinked skills root before canonicalisation would silently - // follow it. `canonicalize` dereferences every component, so by the time - // `starts_with` runs below a symlinked `~/.openhuman/skills` would have - // already resolved elsewhere and the guard is a no-op. - let root_meta = std::fs::symlink_metadata(&skills_root) - .map_err(|e| format!("stat {} failed: {e}", skills_root.display()))?; - if root_meta.file_type().is_symlink() { - log::warn!( - "[skills] uninstall_skill: refused symlinked skills root path={}", - skills_root.display() - ); - return Err(format!( - "skills root {} is a symlink — refusing to resolve", - skills_root.display() - )); - } - - let canonical_root = std::fs::canonicalize(&skills_root) - .map_err(|e| format!("canonicalize {} failed: {e}", skills_root.display()))?; - - let candidate = skills_root.join(&trimmed); - // Reject `~/.openhuman/skills/` aliases that are themselves - // symlinks (e.g. `alias -> other-skill`). Same reasoning as the root - // check: canonicalise would follow the alias and `starts_with` would - // still pass, leading us to delete a different directory than the one - // the UI named. - match std::fs::symlink_metadata(&candidate) { - Ok(m) if m.file_type().is_symlink() => { - log::warn!( - "[skills] uninstall_skill: refused symlinked alias name={trimmed} path={}", - candidate.display() - ); - return Err(format!( - "skill '{trimmed}' is a symlinked alias — refusing to resolve" - )); - } - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(format!("skill '{trimmed}' is not installed")); - } - Err(e) => { - return Err(format!("stat {} failed: {e}", candidate.display())); - } - } - - // After the raw-path symlink guards above, canonicalise to resolve any - // `.` / `..` components introduced by a sneaky input that slipped past - // the earlier separator check, and map a still-possible NotFound race - // to the friendly message. - let canonical_candidate = std::fs::canonicalize(&candidate).map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - format!("skill '{trimmed}' is not installed") - } else { - format!("canonicalize {} failed: {e}", candidate.display()) - } - })?; - - if !canonical_candidate.starts_with(&canonical_root) { - log::warn!( - "[skills] uninstall_skill: path escape rejected candidate={} root={}", - canonical_candidate.display(), - canonical_root.display() - ); - return Err(format!( - "refused to remove {} — path escapes skills root", - canonical_candidate.display() - )); - } - - let meta = std::fs::symlink_metadata(&canonical_candidate) - .map_err(|e| format!("stat {} failed: {e}", canonical_candidate.display()))?; - if meta.file_type().is_symlink() || !meta.is_dir() { - return Err(format!( - "{} is not a directory — refusing to remove", - canonical_candidate.display() - )); - } - - // Best-effort guard: reject directories that don't look like SKILL.md - // skills before we commit to removing them. The subsequent - // `remove_dir_all` is the authoritative operation; a TOCTOU race here - // (file disappears between the check and the remove) is benign — the - // remove would fail with a clear error anyway. - let skill_md = canonical_candidate.join(SKILL_MD); - if !skill_md.exists() { - return Err(format!( - "{} does not look like a SKILL.md skill (missing {SKILL_MD})", - canonical_candidate.display() - )); - } - - log::info!( - "[skills] uninstall_skill: removing name={trimmed} path={}", - canonical_candidate.display() - ); - std::fs::remove_dir_all(&canonical_candidate) - .map_err(|e| format!("remove {} failed: {e}", canonical_candidate.display()))?; - - Ok(UninstallSkillOutcome { - name: trimmed, - removed_path: canonical_candidate.display().to_string(), - scope: SkillScope::User, - }) -} +//! +//! ## Module layout +//! +//! | Module | Contents | +//! |---|---| +//! | [`super::ops_types`] | Core types, constants, and frontmatter helpers | +//! | [`super::ops_discover`] | Scanning root directories, scope resolution, collision handling | +//! | [`super::ops_parse`] | SKILL.md parsing, resource inventory, skill-resource reading | +//! | [`super::ops_create`] | Scaffolding new SKILL.md-based skills on disk | +//! | [`super::ops_install`] | URL-based skill installation over HTTPS | + +// Re-export everything that was previously public from this file so external +// callers are unaffected. +pub use super::ops_create::{create_skill, CreateSkillParams}; +pub use super::ops_discover::{ + discover_skills, init_skills_dir, is_workspace_trusted, load_skills, read_skill_resource, +}; +pub use super::ops_install::{ + install_skill_from_url, uninstall_skill, validate_install_url, validate_resolved_host, + InstallSkillFromUrlOutcome, InstallSkillFromUrlParams, UninstallSkillOutcome, + UninstallSkillParams, DEFAULT_INSTALL_TIMEOUT_SECS, MAX_INSTALL_TIMEOUT_SECS, + MAX_INSTALL_URL_LEN, MAX_SKILL_MD_BYTES, +}; +pub use super::ops_parse::{inventory_resources, parse_skill_md, parse_skill_md_str}; +pub use super::ops_types::{Skill, SkillFrontmatter, SkillScope, MAX_SKILL_RESOURCE_BYTES}; + +#[cfg(test)] +pub(crate) use super::ops_create::{create_skill_inner, slugify_skill_name}; +#[cfg(test)] +pub(crate) use super::ops_discover::discover_skills_inner; +#[cfg(test)] +pub(crate) use super::ops_install::{derive_install_slug, normalize_install_url}; +#[cfg(test)] +pub(crate) use super::ops_types::{MAX_NAME_LEN, RESOURCE_DIRS, SKILL_MD, TRUST_MARKER}; +#[cfg(test)] +pub(crate) use std::path::{Path, PathBuf}; #[cfg(test)] #[path = "ops_tests.rs"] diff --git a/src/openhuman/skills/ops_create.rs b/src/openhuman/skills/ops_create.rs new file mode 100644 index 000000000..2079653c0 --- /dev/null +++ b/src/openhuman/skills/ops_create.rs @@ -0,0 +1,305 @@ +//! Skill creation: scaffolding new SKILL.md-based skills on disk. + +use serde::Deserialize; +use std::path::Path; + +use super::ops_discover::{discover_skills_inner, is_workspace_trusted}; +use super::ops_types::{ + Skill, SkillFrontmatter, SkillScope, MAX_DESCRIPTION_LEN, MAX_NAME_LEN, RESOURCE_DIRS, SKILL_MD, +}; + +/// Input for [`create_skill`]. Mirrors the `skills.create` JSON-RPC payload. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct CreateSkillParams { + /// Human-readable name — slugified into the on-disk folder. + pub name: String, + /// One-line description written into the frontmatter. + pub description: String, + /// Where to install: `user`, `project`, or `legacy`. Defaults to `user`. + #[serde(default)] + pub scope: SkillScope, + /// Optional SPDX license (written to frontmatter `license`). + #[serde(default)] + pub license: Option, + /// Optional author name (written under frontmatter `metadata.author`). + #[serde(default)] + pub author: Option, + /// Optional tags (written under frontmatter `metadata.tags`). + #[serde(default)] + pub tags: Vec, + /// Optional tool hints (written to frontmatter `allowed-tools`). + #[serde(default, rename = "allowed-tools", alias = "allowed_tools")] + pub allowed_tools: Vec, +} + +/// Scaffold a new SKILL.md-based skill on disk. +/// +/// Writes `//SKILL.md` with frontmatter derived from +/// `params` and creates empty `scripts/`, `references/`, `assets/` subdirs +/// so the author has somewhere to drop bundled resources. +/// +/// Scope resolution: +/// * [`SkillScope::User`] → `~/.openhuman/skills/` +/// * [`SkillScope::Project`] → `/.openhuman/skills/`. Requires the +/// trust marker at `/.openhuman/trust` to be present; otherwise +/// rejected with an error. +/// * [`SkillScope::Legacy`] → rejected. Callers must pick one of the +/// above; the legacy `/skills/` layout is read-only going +/// forward. +/// +/// Name hardening: +/// * Slug is derived from `params.name` (lowercased, `[a-z0-9-]` only, +/// non-alphanumeric runs collapsed to a single `-`). +/// * Empty / non-alphanumeric-only names are rejected. +/// * Slug is length-bounded by [`MAX_NAME_LEN`]. +/// * The resolved `/` path is canonicalized and verified +/// to stay inside the canonical scope root (same `starts_with` guard used +/// by [`read_skill_resource`]) to defeat `..` or absolute-path inputs. +/// * Collisions with an existing directory are rejected outright — this +/// function never overwrites. +/// +/// On success the freshly created skill is re-discovered through the standard +/// pipeline and returned so callers can drop it straight into the UI list. +pub fn create_skill(workspace_dir: &Path, params: CreateSkillParams) -> Result { + let home = dirs::home_dir(); + create_skill_inner(home.as_deref(), workspace_dir, params) +} + +pub(crate) fn create_skill_inner( + home_dir: Option<&Path>, + workspace_dir: &Path, + params: CreateSkillParams, +) -> Result { + tracing::debug!( + name = %params.name, + scope = ?params.scope, + workspace = %workspace_dir.display(), + "[skills] create_skill: entry" + ); + + let display_name = params.name.trim(); + if display_name.is_empty() { + return Err("name must not be empty".to_string()); + } + if display_name.len() > MAX_NAME_LEN { + return Err(format!("name exceeds max {MAX_NAME_LEN} chars")); + } + + let description = params.description.trim(); + if description.is_empty() { + return Err("description must not be empty".to_string()); + } + if description.len() > MAX_DESCRIPTION_LEN { + return Err(format!( + "description exceeds max {MAX_DESCRIPTION_LEN} chars" + )); + } + + let slug = slugify_skill_name(display_name)?; + + let scope_root = match params.scope { + SkillScope::User => { + let home = + home_dir.ok_or_else(|| "could not resolve user home directory".to_string())?; + home.join(".openhuman").join("skills") + } + SkillScope::Project => { + if !is_workspace_trusted(workspace_dir) { + return Err(format!( + "workspace {} is not trusted; create {}/.openhuman/trust to enable project-scope skills", + workspace_dir.display(), + workspace_dir.display(), + )); + } + workspace_dir.join(".openhuman").join("skills") + } + SkillScope::Legacy => { + return Err( + "cannot create skill in legacy scope; choose 'user' or 'project'".to_string(), + ); + } + }; + + std::fs::create_dir_all(&scope_root) + .map_err(|e| format!("failed to create skills root {}: {e}", scope_root.display()))?; + + let canonical_root = std::fs::canonicalize(&scope_root).map_err(|e| { + format!( + "failed to canonicalize skills root {}: {e}", + scope_root.display() + ) + })?; + + let skill_dir = canonical_root.join(&slug); + if !skill_dir.starts_with(&canonical_root) { + return Err(format!( + "resolved skill dir {} escapes scope root {}", + skill_dir.display(), + canonical_root.display(), + )); + } + + if skill_dir.exists() { + return Err(format!( + "skill '{slug}' already exists at {}", + skill_dir.display() + )); + } + + std::fs::create_dir_all(&skill_dir) + .map_err(|e| format!("failed to create skill dir {}: {e}", skill_dir.display()))?; + + let skill_md_path = skill_dir.join(SKILL_MD); + let skill_md = render_skill_md( + &slug, + description, + params.license.as_deref(), + params.author.as_deref(), + ¶ms.tags, + ¶ms.allowed_tools, + ); + std::fs::write(&skill_md_path, skill_md) + .map_err(|e| format!("failed to write {}: {e}", skill_md_path.display()))?; + + for sub in RESOURCE_DIRS { + let sub_path = skill_dir.join(sub); + std::fs::create_dir_all(&sub_path) + .map_err(|e| format!("failed to create {}: {e}", sub_path.display()))?; + } + + tracing::info!( + slug = %slug, + scope = ?params.scope, + location = %skill_md_path.display(), + "[skills] create_skill: wrote SKILL.md" + ); + + let trusted = is_workspace_trusted(workspace_dir); + let created = discover_skills_inner(home_dir, Some(workspace_dir), trusted) + .into_iter() + .find(|s| s.name == slug) + .ok_or_else(|| format!("created skill '{slug}' but failed to re-discover"))?; + Ok(created) +} + +/// Convert a human-readable skill name to a filesystem-safe slug. +/// +/// Rules: +/// * ASCII alphanumeric characters are lowercased and kept. +/// * Whitespace, `-`, and `_` collapse to a single `-`. +/// * Any other character is dropped. +/// * Leading / trailing `-` are trimmed. +/// * The empty slug (i.e. the name had no `[a-z0-9]` characters) is rejected. +pub(crate) fn slugify_skill_name(name: &str) -> Result { + let mut out = String::new(); + let mut prev_hyphen = true; + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + prev_hyphen = false; + } else if (ch == '-' || ch == '_' || ch.is_whitespace()) && !prev_hyphen { + out.push('-'); + prev_hyphen = true; + } + } + while out.ends_with('-') { + out.pop(); + } + if out.is_empty() { + return Err(format!( + "name '{name}' has no alphanumeric characters; cannot derive slug" + )); + } + if out.len() > MAX_NAME_LEN { + return Err(format!("slug '{out}' exceeds max {MAX_NAME_LEN} chars")); + } + Ok(out) +} + +/// Render a minimal SKILL.md body for a freshly scaffolded skill. +pub(crate) fn render_skill_md( + slug: &str, + description: &str, + license: Option<&str>, + author: Option<&str>, + tags: &[String], + allowed_tools: &[String], +) -> String { + let mut out = String::new(); + out.push_str("---\n"); + out.push_str(&format!("name: {slug}\n")); + out.push_str(&format!("description: {}\n", yaml_scalar(description))); + if let Some(v) = license { + out.push_str(&format!("license: {}\n", yaml_scalar(v))); + } + let has_metadata = author.is_some() || !tags.is_empty(); + if has_metadata { + out.push_str("metadata:\n"); + if let Some(v) = author { + out.push_str(&format!(" author: {}\n", yaml_scalar(v))); + } + if !tags.is_empty() { + out.push_str(" tags:\n"); + for t in tags { + out.push_str(&format!(" - {}\n", yaml_scalar(t))); + } + } + } + if !allowed_tools.is_empty() { + out.push_str("allowed-tools:\n"); + for t in allowed_tools { + out.push_str(&format!(" - {}\n", yaml_scalar(t))); + } + } + out.push_str("---\n\n"); + out.push_str(&format!("# {slug}\n\n")); + out.push_str(description); + if !description.ends_with('\n') { + out.push('\n'); + } + out.push_str("\n## Instructions\n\n"); + out.push_str("_Describe when and how this skill should be used._\n"); + out +} + +/// Best-effort YAML scalar encoder: pass plain-safe strings through, +/// double-quote anything with structure / whitespace / control chars. +pub(crate) fn yaml_scalar(s: &str) -> String { + let needs_quote = s.is_empty() + || s.chars().any(|c| { + matches!( + c, + ':' | '#' + | '\'' + | '"' + | '\n' + | '\r' + | '\t' + | '[' + | ']' + | '{' + | '}' + | ',' + | '&' + | '*' + | '!' + | '|' + | '>' + | '%' + | '@' + | '`' + ) + }) + || s.starts_with(|c: char| c.is_ascii_whitespace() || c == '-' || c == '?') + || s.ends_with(|c: char| c.is_ascii_whitespace()); + if !needs_quote { + return s.to_string(); + } + let escaped = s + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + format!("\"{escaped}\"") +} diff --git a/src/openhuman/skills/ops_discover.rs b/src/openhuman/skills/ops_discover.rs new file mode 100644 index 000000000..402c41b1e --- /dev/null +++ b/src/openhuman/skills/ops_discover.rs @@ -0,0 +1,378 @@ +//! Skill discovery: scanning root directories, scope resolution, collision handling, +//! and skill resource reading. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::ops_parse::{load_from_legacy_manifest, load_from_skill_md}; +use super::ops_types::{ + Skill, SkillScope, MAX_SKILL_RESOURCE_BYTES, SKILL_JSON, SKILL_MD, TRUST_MARKER, +}; + +/// Initialize the legacy skills directory in the specified workspace. +/// +/// Creates `/skills/` and a placeholder `README.md` so the folder +/// is visible to the user. New-style skills should live under +/// `/.openhuman/skills/` instead, but this directory is kept for +/// backward compatibility. +pub fn init_skills_dir(workspace_dir: &Path) -> Result<(), String> { + let skills_dir = workspace_dir.join("skills"); + std::fs::create_dir_all(&skills_dir).map_err(|e| { + format!( + "failed to create skills directory {}: {e}", + skills_dir.display() + ) + })?; + + let readme_path = skills_dir.join("README.md"); + if !readme_path.exists() { + let content = "# Skills\n\nPut one skill per directory under this folder.\n"; + std::fs::write(&readme_path, content) + .map_err(|e| format!("failed to write {}: {e}", readme_path.display()))?; + } + + Ok(()) +} + +/// Backwards-compatible shim for callers that only have a workspace path. +/// +/// Delegates to [`discover_skills`] with the current user's home directory +/// so user-scope skills (`~/.openhuman/skills/`, `~/.agents/skills/`) are +/// surfaced for existing production callers (`agent::harness::session::builder`, +/// `channels::runtime::startup`). Previously this shim passed `None` for the +/// home directory, which silently dropped user-installed skills from the +/// main runtime path. +/// +/// Project-scope (workspace) skills still take precedence over user-scope +/// on name collisions. +pub fn load_skills(workspace_dir: &Path) -> Vec { + let trusted = is_workspace_trusted(workspace_dir); + let home = dirs::home_dir(); + discover_skills_inner(home.as_deref(), Some(workspace_dir), trusted) +} + +/// Discover skills from every supported location. +/// +/// * `home_dir` — user home (typically `dirs::home_dir()`), scanned for +/// `~/.openhuman/skills/` and `~/.agents/skills/`. +/// * `workspace_dir` — current workspace, scanned for project-scope paths. +/// * `trusted` — whether the caller has verified the project trust marker. +/// Project-scope skills are silently skipped when `false`. +/// +/// On name collisions, project-scope wins over user-scope and a warning is +/// attached to the retained skill. +pub fn discover_skills( + home_dir: Option<&Path>, + workspace_dir: Option<&Path>, + trusted: bool, +) -> Vec { + discover_skills_inner(home_dir, workspace_dir, trusted) +} + +/// Whether the workspace has opted into loading project-scope skills. +/// +/// Looks for `/.openhuman/trust`. The marker file's contents are +/// ignored — presence is sufficient. +pub fn is_workspace_trusted(workspace_dir: &Path) -> bool { + workspace_dir.join(".openhuman").join(TRUST_MARKER).exists() +} + +pub(crate) fn discover_skills_inner( + home_dir: Option<&Path>, + workspace_dir: Option<&Path>, + trusted: bool, +) -> Vec { + // Scan order matters for collision resolution: the last scope to register + // a name wins, so we scan user first, then project, then legacy. + let mut by_name: HashMap = HashMap::new(); + + if let Some(home) = home_dir { + for root in user_roots(home) { + absorb(&mut by_name, scan_root(&root, SkillScope::User)); + } + } + + if let Some(ws) = workspace_dir { + if trusted { + for root in project_roots(ws) { + absorb(&mut by_name, scan_root(&root, SkillScope::Project)); + } + } + // Legacy `/skills/` is always scanned so existing setups + // keep working without requiring users to move files or add the trust + // marker. Flagged with `legacy = true` so the UI can nudge migration. + absorb( + &mut by_name, + scan_root(&ws.join("skills"), SkillScope::Legacy), + ); + } + + let mut out: Vec = by_name.into_values().collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +fn user_roots(home: &Path) -> Vec { + vec![ + home.join(".openhuman").join("skills"), + home.join(".agents").join("skills"), + ] +} + +fn project_roots(workspace: &Path) -> Vec { + vec![ + workspace.join(".openhuman").join("skills"), + workspace.join(".agents").join("skills"), + ] +} + +fn absorb(by_name: &mut HashMap, incoming: Vec) { + for mut skill in incoming { + let key = skill.name.clone(); + if let Some(existing) = by_name.remove(&key) { + // Higher-precedence scope wins; lower loses and is dropped. + let (winner, loser) = if precedence(skill.scope) >= precedence(existing.scope) { + (&mut skill, existing) + } else { + // Put existing back; discard incoming. + let mut kept = existing; + kept.warnings.push(format!( + "name '{}' also declared in {:?} scope at {} (ignored)", + kept.name, + skill.scope, + skill + .location + .as_deref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".to_string()) + )); + by_name.insert(key, kept); + continue; + }; + winner.warnings.push(format!( + "shadowed {:?}-scope skill at {} with same name", + loser.scope, + loser + .location + .as_deref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".to_string()) + )); + } + by_name.insert(key, skill); + } +} + +fn precedence(scope: SkillScope) -> u8 { + match scope { + SkillScope::Legacy => 0, + SkillScope::User => 1, + SkillScope::Project => 2, + } +} + +fn scan_root(root: &Path, scope: SkillScope) -> Vec { + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(_) => return Vec::new(), + }; + + // `read_dir` order is unspecified. When two sibling directories declare + // the same logical `frontmatter.name` (which can differ from the folder + // name), cross-scope/same-scope deduplication downstream would otherwise + // pick a non-deterministic winner across runs. Sort by on-disk directory + // name for a stable, reproducible order. + let mut entries: Vec<_> = entries.flatten().collect(); + entries.sort_by_key(|entry| entry.file_name()); + + let mut out = Vec::new(); + for entry in entries { + // Use `file_type()` rather than `path.is_dir()` so a symlinked + // child cannot be loaded as a skill. `is_dir()` dereferences + // symlinks, which would re-open out-of-tree loading even though + // `walk_files` already rejects symlinks deeper in the resource + // walker. Skip both symlinks and non-directory entries here; if + // the `file_type()` call itself fails (rare — transient I/O), + // treat it as "not safe to traverse" and skip. + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() || !file_type.is_dir() { + continue; + } + let path = entry.path(); + let dir_name = entry.file_name().to_string_lossy().to_string(); + if dir_name.starts_with('.') { + continue; + } + if let Some(skill) = load_skill_dir(&path, &dir_name, scope) { + out.push(skill); + } + } + out +} + +fn load_skill_dir(dir: &Path, dir_name: &str, scope: SkillScope) -> Option { + let skill_md = dir.join(SKILL_MD); + let legacy_manifest = dir.join(SKILL_JSON); + + if skill_md.exists() { + return Some(load_from_skill_md(&skill_md, dir, dir_name, scope)); + } + if legacy_manifest.exists() { + return Some(load_from_legacy_manifest( + &legacy_manifest, + dir, + dir_name, + scope, + )); + } + None +} + +/// Read a bundled skill resource as UTF-8 text, hardened against directory +/// traversal, symlink escape, and oversized payloads. +/// +/// `skill_id` identifies the skill by its discovered `name` — the same field +/// surfaced on [`Skill::name`]. The skill is resolved by running the standard +/// discovery pipeline (`dirs::home_dir()` + `workspace_dir`, honoring the +/// `.openhuman/trust` marker) and locating the matching entry; this keeps the +/// read scoped to legitimately installed skills and reuses all the symlink / +/// traversal hardening already baked into discovery. +/// +/// `relative_path` is resolved relative to the skill's on-disk directory +/// (the parent of its `SKILL.md` / `skill.json`). All of the following are +/// rejected with an error: +/// +/// * paths that canonicalize outside the skill root (traversal), +/// * paths whose final component or any intermediate component is a symlink +/// (link-follow escape), +/// * non-file targets (directories, sockets, fifos), +/// * files larger than [`MAX_SKILL_RESOURCE_BYTES`], +/// * non-UTF-8 byte contents (binary files must be surfaced some other way — +/// no lossy replacement). +/// +/// On success returns the file's contents as an owned `String`. +pub fn read_skill_resource( + workspace_dir: &Path, + skill_id: &str, + relative_path: &Path, +) -> Result { + tracing::debug!( + skill_id = %skill_id, + relative_path = %relative_path.display(), + workspace = %workspace_dir.display(), + "[skills] read_skill_resource: entry" + ); + + if skill_id.trim().is_empty() { + return Err("skill_id must not be empty".to_string()); + } + + let relative_str = relative_path.to_string_lossy(); + if relative_str.trim().is_empty() { + return Err("relative_path must not be empty".to_string()); + } + if relative_path.is_absolute() { + return Err("relative_path must be relative, not absolute".to_string()); + } + // Reject any component that is `..`, is empty, starts with `.`, or is the + // root. `..` is the obvious traversal vector; the others are defense in + // depth against unusual path inputs (e.g. `./`, `//foo`, Windows `C:`). + for component in relative_path.components() { + use std::path::Component; + match component { + Component::Normal(_) => {} + Component::ParentDir => { + return Err("relative_path must not contain '..' components".to_string()); + } + Component::CurDir | Component::RootDir | Component::Prefix(_) => { + return Err("relative_path must be a plain relative path".to_string()); + } + } + } + + // Resolve the skill by running the standard discovery pipeline. We reuse + // `load_skills` (which honors both user and workspace roots plus the + // trust marker) so the resource read is scoped to the exact same set of + // skills the UI would already have shown the user. + let skills = load_skills(workspace_dir); + let skill = skills + .into_iter() + .find(|s| s.name == skill_id) + .ok_or_else(|| format!("skill '{skill_id}' not found"))?; + let skill_root = skill + .location + .as_deref() + .and_then(|p| p.parent()) + .ok_or_else(|| format!("skill '{skill_id}' has no on-disk location"))? + .to_path_buf(); + + // Canonicalize the root first. The root must itself be a real directory + // on disk (not a symlink). Reject early if this fails. + let canonical_root = std::fs::canonicalize(&skill_root).map_err(|e| { + format!( + "failed to canonicalize skill root {}: {e}", + skill_root.display() + ) + })?; + + let requested = canonical_root.join(relative_path); + + // Pre-check the immediate target with `symlink_metadata` so we catch + // symlinked leaves before `canonicalize` silently follows them. + let leaf_meta = std::fs::symlink_metadata(&requested) + .map_err(|e| format!("failed to stat resource {}: {e}", requested.display()))?; + if leaf_meta.file_type().is_symlink() { + return Err("resource path is a symlink".to_string()); + } + if !leaf_meta.is_file() { + return Err("resource path is not a regular file".to_string()); + } + + // Size gate — check via metadata before reading so we never allocate the + // buffer for an oversized file. + let size = leaf_meta.len(); + if size > MAX_SKILL_RESOURCE_BYTES { + return Err(format!( + "resource file is {size} bytes, exceeds limit of {MAX_SKILL_RESOURCE_BYTES}" + )); + } + + // Canonicalize the full path and verify it stays within the skill root. + // This catches any symlink reachable via an intermediate path component + // that was created after our initial checks (race-ish, but the + // `is_symlink` check above makes the obvious attack infeasible). + let canonical_requested = std::fs::canonicalize(&requested).map_err(|e| { + format!( + "failed to canonicalize resource {}: {e}", + requested.display() + ) + })?; + if !canonical_requested.starts_with(&canonical_root) { + return Err(format!( + "resource path escapes skill root: {}", + canonical_requested.display() + )); + } + + // Read the bytes and enforce strict UTF-8 (no lossy replacement — we + // would rather refuse a binary file than silently mangle it). + let bytes = std::fs::read(&canonical_requested).map_err(|e| { + format!( + "failed to read resource {}: {e}", + canonical_requested.display() + ) + })?; + let content = std::str::from_utf8(&bytes) + .map_err(|e| format!("resource is not valid UTF-8 text: {e}"))? + .to_string(); + + tracing::debug!( + skill_id = %skill_id, + bytes = bytes.len(), + "[skills] read_skill_resource: success" + ); + + Ok(content) +} diff --git a/src/openhuman/skills/ops_install.rs b/src/openhuman/skills/ops_install.rs new file mode 100644 index 000000000..9a2a171c2 --- /dev/null +++ b/src/openhuman/skills/ops_install.rs @@ -0,0 +1,740 @@ +//! URL-based skill installation: fetch, validate, and write SKILL.md from a remote URL. + +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use super::ops_discover::{discover_skills_inner, is_workspace_trusted}; +use super::ops_parse::parse_skill_md_str; +use super::ops_types::{SkillFrontmatter, SkillScope, MAX_NAME_LEN, SKILL_MD}; + +/// Default wall-clock budget for the SKILL.md fetch. +pub const DEFAULT_INSTALL_TIMEOUT_SECS: u64 = 60; +/// Hard ceiling callers can request via `timeout_secs`. +pub const MAX_INSTALL_TIMEOUT_SECS: u64 = 600; +/// Upper bound on raw URL length accepted by [`validate_install_url`]. +pub const MAX_INSTALL_URL_LEN: usize = 2048; +/// Upper bound on the fetched SKILL.md body. Single-file skills rarely exceed +/// a few KB; the 1 MiB cap here is a defensive limit against a hostile or +/// misconfigured host streaming an unbounded response into memory. +pub const MAX_SKILL_MD_BYTES: usize = 1024 * 1024; + +/// Input for [`install_skill_from_url`]. Mirrors the `skills.install_from_url` +/// JSON-RPC payload. +#[derive(Debug, Clone, Deserialize)] +pub struct InstallSkillFromUrlParams { + /// Remote SKILL.md URL. Must be `https://`, resolve to a non-private host + /// (see [`validate_install_url`]), and point at a `.md` file after + /// github.com `/blob/` normalization. + pub url: String, + /// Optional wall-clock budget override, in seconds. Defaults to + /// [`DEFAULT_INSTALL_TIMEOUT_SECS`] and is capped at + /// [`MAX_INSTALL_TIMEOUT_SECS`]. + #[serde(default)] + pub timeout_secs: Option, +} + +/// Outcome of a successful install. `new_skills` is the set of skill slugs +/// that appeared in the catalog since the start of the call (post-discovery +/// minus pre-discovery). +#[derive(Debug, Clone, Serialize)] +pub struct InstallSkillFromUrlOutcome { + /// The URL the caller submitted, trimmed. + pub url: String, + /// Human-readable install log — typically `Fetched N bytes from \n + /// Installed to `. Repurposed from the old npx stdout field so the + /// UI success panel keeps the same `

` layout. + pub stdout: String, + /// Non-fatal warnings surfaced during parse (e.g. deprecated top-level + /// `version`/`author`/`tags`). Empty on the happy path. Repurposed from + /// the old npx stderr field. + pub stderr: String, + /// Slugs that appeared in the workspace skill catalog as a result of the + /// install. Usually one, empty only when the SKILL.md could not be + /// enumerated by discovery (rare — indicates workspace trust mismatch). + pub new_skills: Vec, +} + +/// Install a skill by fetching its `SKILL.md` directly over HTTPS and writing +/// it to `/.openhuman/skills//SKILL.md`. +/// +/// Design rationale: openhuman's skill discovery scans +/// `/.openhuman/skills/` (plus `~/.openhuman/skills/` and legacy +/// paths), **not** the per-agent subdirectories that the vercel-labs `skills` +/// CLI writes to (`./claude-code/skills/`, `./cursor/skills/`, â€Ļ). The CLI's +/// agent ecosystem is incompatible with openhuman's skill layout, so we fetch +/// the SKILL.md file directly and install it into a layout discovery sees. +/// +/// Validation applied before any network I/O: +/// * URL length, scheme (`https` only), and host safety via +/// [`validate_install_url`] — rejects loopback, private, link-local, +/// multicast, shared-address ranges, `localhost`, and `.local` / `.localhost` +/// mDNS-style hostnames. +/// * `github.com///blob//

` is rewritten to the raw +/// `raw.githubusercontent.com////

` equivalent so humans can +/// paste the URL they see in the browser. +/// * The path must end in `.md` (case-insensitive). Repo/tree URLs and +/// tarballs are rejected with `unsupported url form:`. +/// * `timeout_secs` is clamped to [`MAX_INSTALL_TIMEOUT_SECS`]. +/// +/// Runtime: +/// * Body size is capped by [`MAX_SKILL_MD_BYTES`] (1 MiB). The advertised +/// `Content-Length` is checked up front; the buffered body length is +/// checked again after the download as defense against a lying header. +/// * Frontmatter is validated — `name` and `description` are required per +/// the agentskills.io spec. +/// * The slug is derived from `metadata.id` when present, otherwise the +/// sanitized `name` field. Collision with an existing directory is fatal +/// (no silent overwrite). +/// * Write is atomic: `SKILL.md.tmp` in the target dir, then `rename` on +/// success. +/// +/// On success the full post-install skills catalog is re-discovered and the +/// outcome includes the list of skill slugs that appeared since the start of +/// the call. +pub async fn install_skill_from_url( + workspace_dir: &Path, + params: InstallSkillFromUrlParams, +) -> Result { + let raw_url = params.url.trim().to_string(); + validate_install_url(&raw_url)?; + + let timeout_secs = params + .timeout_secs + .unwrap_or(DEFAULT_INSTALL_TIMEOUT_SECS) + .clamp(1, MAX_INSTALL_TIMEOUT_SECS); + + let fetch_url = normalize_install_url(&raw_url)?; + + // Second-layer SSRF guard: a public-looking hostname can still resolve + // to a loopback / private / link-local address (DNS-to-private-IP). We + // resolve the host up-front and reject if any returned IP is private. + // Known caveat: this does not fully prevent DNS rebinding — reqwest's + // resolver may see different answers than ours. Closing that gap requires + // pinning a `SocketAddr` and passing it to reqwest via a custom resolver, + // tracked separately. + validate_resolved_host(&fetch_url).await?; + + tracing::debug!( + raw_url = %raw_url, + fetch_url = %fetch_url, + workspace = %workspace_dir.display(), + timeout_secs = timeout_secs, + "[skills] install_skill_from_url: entry" + ); + + let home = dirs::home_dir(); + let trusted_before = is_workspace_trusted(workspace_dir); + let before: std::collections::HashSet = + discover_skills_inner(home.as_deref(), Some(workspace_dir), trusted_before) + .into_iter() + .map(|s| s.name) + .collect(); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| format!("fetch failed: build http client: {e}"))?; + + tracing::info!( + fetch_url = %fetch_url, + "[skills] install_skill_from_url: fetching SKILL.md" + ); + + let response = match client.get(&fetch_url).send().await { + Ok(resp) => resp, + Err(e) => { + if e.is_timeout() { + return Err(format!("fetch timed out after {timeout_secs}s")); + } + return Err(format!("fetch failed: {e}")); + } + }; + + let status = response.status(); + if !status.is_success() { + return Err(format!( + "fetch failed: {fetch_url} returned status {}", + status.as_u16() + )); + } + + if let Some(len) = response.content_length() { + if len > MAX_SKILL_MD_BYTES as u64 { + return Err(format!( + "fetch too large: {} bytes exceeds {MAX_SKILL_MD_BYTES} limit", + len + )); + } + } + + let bytes = match response.bytes().await { + Ok(b) => b, + Err(e) => { + if e.is_timeout() { + return Err(format!("fetch timed out after {timeout_secs}s")); + } + return Err(format!("fetch failed: reading body: {e}")); + } + }; + + if bytes.len() > MAX_SKILL_MD_BYTES { + return Err(format!( + "fetch too large: {} bytes exceeds {MAX_SKILL_MD_BYTES} limit", + bytes.len() + )); + } + + let content = String::from_utf8(bytes.to_vec()) + .map_err(|e| format!("invalid SKILL.md: body is not valid utf-8: {e}"))?; + + let (frontmatter, _body, parse_warnings) = parse_skill_md_str(&content).ok_or_else(|| { + "invalid SKILL.md: frontmatter block opened with `---` but never terminated".to_string() + })?; + + if frontmatter.name.trim().is_empty() { + return Err("invalid SKILL.md: missing required field 'name'".to_string()); + } + if frontmatter.description.trim().is_empty() { + return Err("invalid SKILL.md: missing required field 'description'".to_string()); + } + + let slug = derive_install_slug(&frontmatter)?; + + // Install to user scope (`~/.openhuman/skills/`), which `discover_skills` + // scans unconditionally. Project scope (`/.openhuman/skills/`) is gated on + // a `/.openhuman/trust` marker and would render the install invisible to the + // skills list until the user opts the workspace into trust. + let skills_root = home + .as_deref() + .ok_or_else(|| "write failed: unable to resolve home directory".to_string())? + .join(".openhuman") + .join("skills"); + let target_dir = skills_root.join(&slug); + if target_dir.exists() { + return Err(format!( + "skill already installed as {slug:?} at {}", + target_dir.display() + )); + } + + std::fs::create_dir_all(&target_dir).map_err(|e| { + format!( + "write failed: create directory {}: {e}", + target_dir.display() + ) + })?; + + let target_file = target_dir.join(SKILL_MD); + let temp_file = target_dir.join("SKILL.md.tmp"); + + // Roll the partial install back if either filesystem op fails so the + // next retry isn't blocked by a leftover empty directory. Cleanup is + // best-effort — if it fails, we surface the original write error. + let write_result: Result<(), String> = std::fs::write(&temp_file, &content) + .map_err(|e| format!("write failed: {}: {e}", temp_file.display())) + .and_then(|_| { + std::fs::rename(&temp_file, &target_file) + .map_err(|e| format!("write failed: rename {}: {e}", target_file.display())) + }); + + if let Err(e) = write_result { + let _ = std::fs::remove_file(&temp_file); + if let Err(rm_err) = std::fs::remove_dir(&target_dir) { + tracing::warn!( + target_dir = %target_dir.display(), + error = %rm_err, + "[skills] install_skill_from_url: rollback remove_dir failed (non-fatal)" + ); + } else { + tracing::warn!( + target_dir = %target_dir.display(), + "[skills] install_skill_from_url: rolled back partial install after write failure" + ); + } + return Err(e); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o644); + if let Err(e) = std::fs::set_permissions(&target_file, perms) { + tracing::warn!( + target = %target_file.display(), + error = %e, + "[skills] install_skill_from_url: chmod 0644 failed (non-fatal)" + ); + } + } + + let trusted_after = is_workspace_trusted(workspace_dir); + let after = discover_skills_inner(home.as_deref(), Some(workspace_dir), trusted_after); + let new_skills: Vec = after + .into_iter() + .map(|s| s.name) + .filter(|name| !before.contains(name)) + .collect(); + + tracing::info!( + raw_url = %raw_url, + fetch_url = %fetch_url, + slug = %slug, + bytes = content.len(), + new_count = new_skills.len(), + "[skills] install_skill_from_url: completed" + ); + + let stdout = format!( + "Fetched {} bytes from {fetch_url}\nInstalled to {}", + content.len(), + target_file.display() + ); + let stderr = parse_warnings.join("\n"); + + Ok(InstallSkillFromUrlOutcome { + url: raw_url, + stdout, + stderr, + new_skills, + }) +} + +/// Input for [`uninstall_skill`]. Mirrors the `skills.uninstall` JSON-RPC payload. +#[derive(Debug, Clone, Deserialize)] +pub struct UninstallSkillParams { + /// On-disk slug of the installed skill — the directory name under + /// `~/.openhuman/skills//`. Retained as `name` for wire-format + /// back-compat with pre-existing clients; semantics are slug-only. + pub name: String, +} + +/// Outcome of a successful uninstall. +#[derive(Debug, Clone, Serialize)] +pub struct UninstallSkillOutcome { + /// The normalised slug that was removed. + pub name: String, + /// Absolute on-disk path that was deleted (post-canonicalisation). + pub removed_path: String, + /// Scope the uninstall applied to. Always `User` today. + pub scope: SkillScope, +} + +/// Remove an installed user-scope SKILL.md skill from `~/.openhuman/skills/`. +/// +/// Only user-scope uninstalls are supported. Resolution is defensive: +/// canonicalises paths, refuses symlinks, requires SKILL.md to be present. +/// +/// `home_dir_override` is for tests; production callers pass `None`. +pub fn uninstall_skill( + params: UninstallSkillParams, + home_dir_override: Option<&Path>, +) -> Result { + let trimmed = params.name.trim().to_string(); + if trimmed.is_empty() { + return Err("skill name is required".to_string()); + } + if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { + log::warn!( + "[skills] uninstall_skill: rejected name with path separators name={:?}", + trimmed + ); + return Err(format!( + "skill name '{trimmed}' must not contain path separators" + )); + } + if trimmed.len() > MAX_NAME_LEN { + return Err(format!( + "skill name is {} chars (max {MAX_NAME_LEN})", + trimmed.len() + )); + } + + let home = match home_dir_override + .map(|p| p.to_path_buf()) + .or_else(dirs::home_dir) + { + Some(h) => h, + None => return Err("could not resolve user home directory".to_string()), + }; + + let skills_root = home.join(".openhuman").join("skills"); + if !skills_root.exists() { + return Err(format!( + "no user skills directory at {}", + skills_root.display() + )); + } + + let root_meta = std::fs::symlink_metadata(&skills_root) + .map_err(|e| format!("stat {} failed: {e}", skills_root.display()))?; + if root_meta.file_type().is_symlink() { + log::warn!( + "[skills] uninstall_skill: refused symlinked skills root path={}", + skills_root.display() + ); + return Err(format!( + "skills root {} is a symlink — refusing to resolve", + skills_root.display() + )); + } + + let canonical_root = std::fs::canonicalize(&skills_root) + .map_err(|e| format!("canonicalize {} failed: {e}", skills_root.display()))?; + + let candidate = skills_root.join(&trimmed); + match std::fs::symlink_metadata(&candidate) { + Ok(m) if m.file_type().is_symlink() => { + log::warn!( + "[skills] uninstall_skill: refused symlinked alias name={trimmed} path={}", + candidate.display() + ); + return Err(format!( + "skill '{trimmed}' is a symlinked alias — refusing to resolve" + )); + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(format!("skill '{trimmed}' is not installed")); + } + Err(e) => { + return Err(format!("stat {} failed: {e}", candidate.display())); + } + } + + let canonical_candidate = std::fs::canonicalize(&candidate).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + format!("skill '{trimmed}' is not installed") + } else { + format!("canonicalize {} failed: {e}", candidate.display()) + } + })?; + + if !canonical_candidate.starts_with(&canonical_root) { + log::warn!( + "[skills] uninstall_skill: path escape rejected candidate={} root={}", + canonical_candidate.display(), + canonical_root.display() + ); + return Err(format!( + "refused to remove {} — path escapes skills root", + canonical_candidate.display() + )); + } + + let meta = std::fs::symlink_metadata(&canonical_candidate) + .map_err(|e| format!("stat {} failed: {e}", canonical_candidate.display()))?; + if meta.file_type().is_symlink() || !meta.is_dir() { + return Err(format!( + "{} is not a directory — refusing to remove", + canonical_candidate.display() + )); + } + + let skill_md = canonical_candidate.join(SKILL_MD); + if !skill_md.exists() { + return Err(format!( + "{} does not look like a SKILL.md skill (missing {SKILL_MD})", + canonical_candidate.display() + )); + } + + log::info!( + "[skills] uninstall_skill: removing name={trimmed} path={}", + canonical_candidate.display() + ); + std::fs::remove_dir_all(&canonical_candidate) + .map_err(|e| format!("remove {} failed: {e}", canonical_candidate.display()))?; + + Ok(UninstallSkillOutcome { + name: trimmed, + removed_path: canonical_candidate.display().to_string(), + scope: SkillScope::User, + }) +} + +/// Rewrite `github.com///blob//` into its raw counterpart +/// so a URL copied from a browser's GitHub page resolves to the file body +/// instead of the HTML wrapper. Any other host is returned unchanged. +/// +/// Also enforces that the final path ends in `.md` (case-insensitive). Tree, +/// commit, and whole-repo URLs are rejected here — they require a +/// fundamentally different install path (recursive fetch / tarball) that is +/// out of scope for single-file SKILL.md installs. +pub(crate) fn normalize_install_url(raw: &str) -> Result { + let parsed = + url::Url::parse(raw).map_err(|e| format!("unsupported url form: parse {raw:?}: {e}"))?; + let host = parsed.host_str().unwrap_or("").to_ascii_lowercase(); + + let normalized = if host == "github.com" { + let segments: Vec<&str> = parsed + .path_segments() + .map(|it| it.collect()) + .unwrap_or_default(); + if segments.len() >= 5 && segments[2] == "blob" { + let owner = segments[0]; + let repo = segments[1]; + let branch = segments[3]; + let rest = segments[4..].join("/"); + format!("https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{rest}") + } else if segments.len() >= 3 && (segments[2] == "tree" || segments[2] == "raw") { + return Err(format!( + "unsupported url form: only direct SKILL.md links are supported, got {raw:?} (tree/dir URLs are not yet supported)" + )); + } else if segments.len() <= 2 { + return Err(format!( + "unsupported url form: only direct SKILL.md links are supported, got {raw:?} (whole-repo URLs are not yet supported)" + )); + } else { + raw.to_string() + } + } else { + raw.to_string() + }; + + let check = url::Url::parse(&normalized) + .map_err(|e| format!("unsupported url form: parse normalized {normalized:?}: {e}"))?; + let path_lower = check.path().to_ascii_lowercase(); + if !path_lower.ends_with(".md") { + return Err(format!( + "unsupported url form: path must end in .md, got {normalized:?}" + )); + } + + Ok(normalized) +} + +/// Derive the install directory slug from the SKILL.md frontmatter. +/// +/// Prefers `metadata.id` (the spec-aligned identifier) when present. Falls +/// back to a sanitized form of `name`: +/// * lowercase ASCII +/// * non-alphanumeric runs collapsed to a single `-` +/// * leading/trailing `-` trimmed +/// +/// Rejects the empty string and paths that would escape the skills root +/// (`..`, `/`, `\`). Max length is [`MAX_NAME_LEN`]. +pub(crate) fn derive_install_slug(fm: &SkillFrontmatter) -> Result { + let candidate = fm + .metadata + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| fm.name.clone()); + + let mut out = String::with_capacity(candidate.len()); + let mut last_dash = false; + for ch in candidate.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + last_dash = false; + } else if !last_dash && !out.is_empty() { + out.push('-'); + last_dash = true; + } + } + while out.ends_with('-') { + out.pop(); + } + + if out.is_empty() { + return Err( + "invalid SKILL.md: cannot derive slug from empty name/id — set a value in frontmatter" + .to_string(), + ); + } + if out.len() > MAX_NAME_LEN { + return Err(format!( + "invalid SKILL.md: derived slug {out:?} exceeds {MAX_NAME_LEN} chars" + )); + } + if out.contains("..") || out.contains('/') || out.contains('\\') { + return Err(format!( + "invalid SKILL.md: derived slug {out:?} contains forbidden path components" + )); + } + + Ok(out) +} + +/// Validate a remote skill install URL. Returns `Ok(())` when the URL is +/// well-formed, uses `https`, and points at a public host. +/// +/// Rejects: +/// * empty string or > [`MAX_INSTALL_URL_LEN`] bytes +/// * non-`https` schemes (including `http`, `ftp`, `file`, `git+ssh`) +/// * missing or empty host +/// * `localhost`, `*.localhost`, `*.local` +/// * IPv4 literals in loopback (127.0.0.0/8), private (10/8, 172.16/12, +/// 192.168/16), link-local (169.254/16), shared-address (100.64/10), +/// multicast, broadcast, or unspecified (0.0.0.0) ranges +/// * IPv6 literals in loopback (::1), unspecified (::), unique-local +/// (fc00::/7), link-local (fe80::/10), or multicast (ff00::/8) +pub fn validate_install_url(raw: &str) -> Result<(), String> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("url must not be empty".to_string()); + } + if trimmed.len() > MAX_INSTALL_URL_LEN { + return Err(format!( + "url exceeds max {MAX_INSTALL_URL_LEN} chars (got {})", + trimmed.len() + )); + } + let parsed = url::Url::parse(trimmed).map_err(|e| format!("invalid url {trimmed:?}: {e}"))?; + if parsed.scheme() != "https" { + return Err(format!( + "url scheme {:?} not allowed; https only", + parsed.scheme() + )); + } + let host = parsed + .host_str() + .ok_or_else(|| format!("url {trimmed:?} has no host"))?; + if host.is_empty() { + return Err(format!("url {trimmed:?} has empty host")); + } + if is_blocked_install_host(host) { + return Err(format!( + "host {host:?} not allowed (loopback/private/link-local/multicast)" + )); + } + Ok(()) +} + +/// Resolve the host in the given URL and reject if any returned IP falls in +/// loopback / private / link-local / multicast / unspecified ranges. +/// +/// Covers the DNS-to-private-IP SSRF vector: a public-looking hostname can +/// still resolve to 127.0.0.1 / 169.254.x / fc00::/7 etc., which +/// [`validate_install_url`] alone cannot detect because it only inspects +/// literal IP hosts. +/// +/// Caveat: does **not** close the DNS-rebinding gap. `reqwest` performs its +/// own DNS lookup on the GET below, and a rebinding server can answer the +/// check with a public IP and answer reqwest with a private one. Full +/// mitigation requires resolving to a `SocketAddr` here and passing it to +/// reqwest via a custom resolver that only honours the pinned address. +pub async fn validate_resolved_host(raw_url: &str) -> Result<(), String> { + let parsed = url::Url::parse(raw_url) + .map_err(|e| format!("invalid url {raw_url:?} during DNS guard: {e}"))?; + let host = parsed + .host_str() + .ok_or_else(|| format!("url {raw_url:?} has no host (DNS guard)"))?; + // `tokio::net::lookup_host` wants "host:port". Default https → 443. + let port = parsed.port_or_known_default().unwrap_or(443); + // IPv6 literal hosts come back bracketed from `url::Url`; `lookup_host` + // needs the bracketed form for IPv6 to parse correctly. + let lookup_target = if parsed + .host() + .map(|h| matches!(h, url::Host::Ipv6(_))) + .unwrap_or(false) + { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + }; + + tracing::debug!( + host = %host, + port = port, + "[skills] validate_resolved_host: resolving" + ); + + let mut addrs = tokio::net::lookup_host(&lookup_target) + .await + .map_err(|e| format!("dns lookup failed for {host:?}: {e}"))? + .peekable(); + if addrs.peek().is_none() { + return Err(format!("host {host:?} resolved to no IP addresses")); + } + for addr in addrs { + let ip = addr.ip(); + match ip { + std::net::IpAddr::V4(v4) => { + if is_private_v4(&v4) { + tracing::warn!( + host = %host, + resolved = %v4, + "[skills] validate_resolved_host: rejected private IPv4" + ); + return Err(format!( + "host {host:?} resolved to non-public IPv4 {v4} (loopback/private/link-local)" + )); + } + } + std::net::IpAddr::V6(v6) => { + if is_private_v6(&v6) { + tracing::warn!( + host = %host, + resolved = %v6, + "[skills] validate_resolved_host: rejected private IPv6" + ); + return Err(format!( + "host {host:?} resolved to non-public IPv6 {v6} (loopback/ula/link-local)" + )); + } + } + } + } + Ok(()) +} + +fn is_blocked_install_host(host: &str) -> bool { + let lower = host.to_ascii_lowercase(); + // url::Url::host_str returns IPv6 literals wrapped in brackets (e.g. "[::1]"). + // Strip them before attempting Ipv6Addr parse. + let stripped = lower + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(&lower); + if stripped == "localhost" || stripped.ends_with(".localhost") || stripped.ends_with(".local") { + return true; + } + if let Ok(v4) = stripped.parse::() { + return is_private_v4(&v4); + } + if let Ok(v6) = stripped.parse::() { + return is_private_v6(&v6); + } + false +} + +fn is_private_v4(ip: &Ipv4Addr) -> bool { + if ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_unspecified() + || ip.is_multicast() + { + return true; + } + let [a, b, _, _] = ip.octets(); + // 100.64.0.0/10 shared address (CGN) + if a == 100 && (64..=127).contains(&b) { + return true; + } + // 0.0.0.0/8 + if a == 0 { + return true; + } + false +} + +fn is_private_v6(ip: &Ipv6Addr) -> bool { + if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() { + return true; + } + let first = ip.segments()[0]; + // fc00::/7 unique-local + if (first & 0xfe00) == 0xfc00 { + return true; + } + // fe80::/10 link-local + if (first & 0xffc0) == 0xfe80 { + return true; + } + false +} diff --git a/src/openhuman/skills/ops_parse.rs b/src/openhuman/skills/ops_parse.rs new file mode 100644 index 000000000..91c7e94e4 --- /dev/null +++ b/src/openhuman/skills/ops_parse.rs @@ -0,0 +1,308 @@ +//! SKILL.md parsing, resource inventory, and skill-resource reading. + +use std::path::{Path, PathBuf}; + +use super::ops_types::{extract_author, extract_tags, extract_version}; +use super::ops_types::{ + LegacySkillManifest, Skill, SkillFrontmatter, SkillScope, MAX_DESCRIPTION_LEN, MAX_NAME_LEN, + RESOURCE_DIRS, +}; + +/// Split a `SKILL.md` file into parsed frontmatter and the remaining body. +/// +/// Accepts frontmatter delimited by leading `---` lines. Returns `None` when +/// the file cannot be read or the frontmatter block is unterminated. +/// +/// The third element of the tuple carries parse-level diagnostics — for now +/// just the YAML deserialisation error when frontmatter exists but is +/// malformed. Callers merge these into the skill's user-visible warnings so +/// the catalog surfaces the real cause instead of a generic "could not parse" +/// placeholder. +pub fn parse_skill_md(path: &Path) -> Option<(SkillFrontmatter, String, Vec)> { + let content = std::fs::read_to_string(path).ok()?; + parse_skill_md_str(&content) +} + +/// Content-only variant of [`parse_skill_md`] used when the SKILL.md has been +/// fetched over HTTPS (see [`install_skill_from_url`]) and has not yet landed +/// on disk. Returns `None` when the frontmatter block is opened with `---` but +/// never terminated — the same failure mode the file-based parser rejects. +pub fn parse_skill_md_str(content: &str) -> Option<(SkillFrontmatter, String, Vec)> { + let mut lines = content.lines(); + let first = lines.next()?; + if first.trim() != "---" { + // No frontmatter — treat whole file as body. + return Some((SkillFrontmatter::default(), content.to_string(), Vec::new())); + } + + let mut yaml = String::new(); + let mut terminated = false; + let mut body = String::new(); + for line in lines { + if line.trim() == "---" { + terminated = true; + continue; + } + if !terminated { + yaml.push_str(line); + yaml.push('\n'); + } else { + body.push_str(line); + body.push('\n'); + } + } + + if !terminated { + return None; + } + + let mut parse_warnings = Vec::new(); + let frontmatter = match serde_yaml::from_str::(&yaml) { + Ok(fm) => fm, + Err(err) => { + log::warn!("[skills] failed to parse frontmatter: {err}"); + parse_warnings.push(format!("frontmatter parse error: {err}")); + SkillFrontmatter::default() + } + }; + + Some((frontmatter, body, parse_warnings)) +} + +/// Shallow-scan a skill directory for bundled resources. +/// +/// Returns every file (relative to `dir`) under any of the conventional +/// resource subdirectories (`scripts/`, `references/`, `assets/`). Deeper +/// nesting is walked recursively. +pub fn inventory_resources(dir: &Path) -> Vec { + let mut out = Vec::new(); + for sub in RESOURCE_DIRS { + let root = dir.join(sub); + // `root.is_dir()` follows symlinks, so a `scripts -> /some/other/tree` + // symlink would still pass and `walk_files` would inventory the + // external tree. Use `symlink_metadata` for a non-dereferencing check + // and reject symlinked roots outright; `walk_files` already guards + // deeper symlinks inside the tree. + let meta = match std::fs::symlink_metadata(&root) { + Ok(m) => m, + Err(_) => continue, + }; + if meta.file_type().is_symlink() || !meta.is_dir() { + continue; + } + walk_files(&root, dir, &mut out); + } + out.sort(); + out +} + +pub(crate) fn walk_files(current: &Path, base: &Path, out: &mut Vec) { + let entries = match std::fs::read_dir(current) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + // Use `file_type()` — not `is_dir()` / `is_file()` — so we can detect and + // skip symlinks before traversing. `is_dir()`/`is_file()` follow symlinks + // and would cause unbounded recursion on a cycle (e.g. `resources/self -> + // resources/`) or silent leakage outside the skill directory when a + // symlink points at `/`, `/etc`, or another skill's tree. + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() { + continue; + } + let path = entry.path(); + if file_type.is_dir() { + walk_files(&path, base, out); + } else if file_type.is_file() { + if let Ok(rel) = path.strip_prefix(base) { + out.push(rel.to_path_buf()); + } + } + } +} + +pub(crate) fn first_body_line(body: &str) -> Option { + for line in body.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + return Some(trimmed.to_string()); + } + None +} + +/// Load a skill from a `SKILL.md` file. +pub(crate) fn load_from_skill_md( + skill_md: &Path, + dir: &Path, + dir_name: &str, + scope: SkillScope, +) -> Skill { + let mut warnings = Vec::new(); + let (frontmatter, body) = match parse_skill_md(skill_md) { + Some((fm, body, parse_warnings)) => { + warnings.extend(parse_warnings); + (fm, body) + } + None => { + warnings.push(format!( + "could not parse {} — exposing directory as placeholder", + skill_md.display() + )); + (SkillFrontmatter::default(), String::new()) + } + }; + + let name = if frontmatter.name.trim().is_empty() { + warnings.push("frontmatter missing 'name'; using directory name".to_string()); + dir_name.to_string() + } else { + if frontmatter.name != dir_name { + warnings.push(format!( + "frontmatter name '{}' does not match directory '{}'", + frontmatter.name, dir_name + )); + } + if frontmatter.name.len() > MAX_NAME_LEN { + warnings.push(format!( + "frontmatter name is {} chars (max recommended: {})", + frontmatter.name.len(), + MAX_NAME_LEN + )); + } + frontmatter.name.clone() + }; + + let description = if frontmatter.description.trim().is_empty() { + warnings + .push("frontmatter missing 'description'; falling back to first body line".to_string()); + first_body_line(&body).unwrap_or_else(|| "No description provided".to_string()) + } else { + if frontmatter.description.len() > MAX_DESCRIPTION_LEN { + warnings.push(format!( + "description is {} chars (max recommended: {})", + frontmatter.description.len(), + MAX_DESCRIPTION_LEN + )); + } + frontmatter.description.clone() + }; + + let version = extract_version(&frontmatter, &mut warnings); + let author = extract_author(&frontmatter, &mut warnings); + let tags = extract_tags(&frontmatter, &mut warnings); + let tools = frontmatter.allowed_tools.clone(); + + Skill { + name, + dir_name: dir_name.to_string(), + description, + version, + author, + tags, + tools, + prompts: Vec::new(), + location: Some(skill_md.to_path_buf()), + frontmatter, + resources: inventory_resources(dir), + scope, + legacy: false, + warnings, + } +} + +/// Load a skill from a legacy `skill.json` manifest. +pub(crate) fn load_from_legacy_manifest( + manifest_path: &Path, + dir: &Path, + dir_name: &str, + scope: SkillScope, +) -> Skill { + let mut warnings = vec![format!( + "skill uses legacy skill.json; migrate to SKILL.md frontmatter" + )]; + let parsed = std::fs::read_to_string(manifest_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()); + + let manifest = parsed.unwrap_or_else(|| { + warnings.push(format!( + "could not parse {} as JSON; using directory name", + manifest_path.display() + )); + LegacySkillManifest { + name: dir_name.to_string(), + description: String::new(), + version: String::new(), + author: None, + tags: Vec::new(), + tools: Vec::new(), + prompts: Vec::new(), + } + }); + + let name = if manifest.name.trim().is_empty() { + dir_name.to_string() + } else { + manifest.name + }; + + // `load_from_legacy_manifest` is only called when SKILL.md is absent + // (see load_skill_dir), so there is no SKILL.md to fall back to here. + let description = if manifest.description.is_empty() { + "No description provided".to_string() + } else { + manifest.description + }; + + let location = Some(manifest_path.to_path_buf()); + + Skill { + name, + dir_name: dir_name.to_string(), + description, + version: manifest.version, + author: manifest.author, + tags: manifest.tags, + tools: manifest.tools, + prompts: manifest.prompts, + location, + frontmatter: SkillFrontmatter::default(), + resources: inventory_resources(dir), + scope, + legacy: true, + warnings, + } +} + +impl Skill { + /// Re-read the SKILL.md body (everything after the YAML frontmatter + /// block) from disk. Returns `None` for legacy `skill.json` skills, + /// for skills whose `location` points nowhere, or when the file + /// cannot be parsed as a SKILL.md document. + pub fn read_body(&self) -> Option { + if self.legacy { + log::debug!( + "[skills:inject] read_body skipped for legacy skill.json skill name={}", + self.name + ); + return None; + } + let path = self.location.as_ref()?; + match parse_skill_md(path) { + Some((_, body, _)) => Some(body), + None => { + log::warn!( + "[skills:inject] read_body failed to parse {} for skill {}", + path.display(), + self.name + ); + None + } + } + } +} diff --git a/src/openhuman/skills/ops_types.rs b/src/openhuman/skills/ops_types.rs new file mode 100644 index 000000000..79793bf5f --- /dev/null +++ b/src/openhuman/skills/ops_types.rs @@ -0,0 +1,192 @@ +//! Core types, constants, and frontmatter helpers for the skills subsystem. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +pub(crate) const TRUST_MARKER: &str = "trust"; +pub(crate) const SKILL_MD: &str = "SKILL.md"; +pub(crate) const SKILL_JSON: &str = "skill.json"; +pub(crate) const MAX_NAME_LEN: usize = 64; +pub(crate) const MAX_DESCRIPTION_LEN: usize = 1024; +pub(crate) const RESOURCE_DIRS: &[&str] = &["scripts", "references", "assets"]; + +/// Upper bound on resource payload size (in bytes) returned by +/// [`read_skill_resource`]. 128 KB is large enough for a typical SKILL-bundled +/// script or reference doc but small enough to keep the JSON-RPC payload and +/// UI memory footprint bounded even when a skill author bundles something +/// unusually chonky (e.g. a minified binary fixture). Requests for files +/// larger than this limit are rejected outright — callers must stream or +/// download the file via another mechanism. +pub const MAX_SKILL_RESOURCE_BYTES: u64 = 128 * 1024; + +/// Where the skill was discovered. Determines precedence on name collision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SkillScope { + /// Skill shipped with the user's global config (`~/.openhuman/skills/...`). + User, + /// Skill shipped with the current workspace (`/.openhuman/skills/...`). + /// Requires the trust marker to be loaded. + Project, + /// Skill discovered under the legacy `/skills/` layout. + Legacy, +} + +impl Default for SkillScope { + fn default() -> Self { + Self::User + } +} + +/// Parsed frontmatter of a `SKILL.md` file. +/// +/// Matches the agentskills.io SKILL.md spec: `name` and `description` are +/// required; `license`, `compatibility`, `metadata`, and `allowed-tools` are +/// optional. Spec additions land in [`Self::extra`] via `#[serde(flatten)]`. +/// +/// Version, author, tags, and other non-required fields belong under +/// [`Self::metadata`]. Writers that still put them at the top level are +/// accepted with a migration warning. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SkillFrontmatter { + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub compatibility: Option, + /// Spec-compliant metadata map. Version, author, tags, and other + /// non-required fields live here. + #[serde(default)] + pub metadata: HashMap, + /// Tools the skill author asserts their instructions rely on + /// (non-binding hint; the host decides what to expose). + #[serde(default, rename = "allowed-tools", alias = "allowed_tools")] + pub allowed_tools: Vec, + /// Forward-compat hatch for spec additions. Non-spec top-level keys + /// (including legacy `version`, `author`, `tags`) land here and trigger + /// a migration warning when read. + #[serde(flatten)] + pub extra: HashMap, +} + +pub(crate) fn metadata_string(fm: &SkillFrontmatter, key: &str) -> Option { + fm.metadata + .get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +pub(crate) fn metadata_string_seq(value: &serde_yaml::Value) -> Vec { + value + .as_sequence() + .map(|seq| { + seq.iter() + .filter_map(|t| t.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn extract_version(fm: &SkillFrontmatter, warnings: &mut Vec) -> String { + if let Some(v) = metadata_string(fm, "version") { + return v; + } + if let Some(v) = fm.extra.get("version").and_then(|v| v.as_str()) { + log::warn!("[skills] top-level 'version' is deprecated; move under 'metadata.version'"); + warnings + .push("top-level 'version' is deprecated; move under 'metadata.version'".to_string()); + return v.to_string(); + } + String::new() +} + +pub(crate) fn extract_author(fm: &SkillFrontmatter, warnings: &mut Vec) -> Option { + if let Some(v) = metadata_string(fm, "author") { + return Some(v); + } + if let Some(v) = fm.extra.get("author").and_then(|v| v.as_str()) { + log::warn!("[skills] top-level 'author' is deprecated; move under 'metadata.author'"); + warnings.push("top-level 'author' is deprecated; move under 'metadata.author'".to_string()); + return Some(v.to_string()); + } + None +} + +pub(crate) fn extract_tags(fm: &SkillFrontmatter, warnings: &mut Vec) -> Vec { + if let Some(v) = fm.metadata.get("tags") { + return metadata_string_seq(v); + } + if let Some(v) = fm.extra.get("tags") { + log::warn!("[skills] top-level 'tags' is deprecated; move under 'metadata.tags'"); + warnings.push("top-level 'tags' is deprecated; move under 'metadata.tags'".to_string()); + return metadata_string_seq(v); + } + Vec::new() +} + +/// A discovered skill. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Skill { + /// Display name (from frontmatter, falls back to directory name). + pub name: String, + /// On-disk slug — the directory name under `~/.openhuman/skills/` (user + /// scope) or the workspace skills directory (project scope). This is the + /// identifier the uninstall RPC resolves against; it may differ from + /// [`Skill::name`] when frontmatter declares a mismatched display name. + #[serde(default)] + pub dir_name: String, + /// Short description used in the catalog summary. + pub description: String, + /// Version string, if declared. + pub version: String, + /// Author string, if declared. + pub author: Option, + /// Tags declared in frontmatter. + pub tags: Vec, + /// Tool hint declared in frontmatter (`allowed-tools`). + #[serde(default)] + pub tools: Vec, + /// Prompt files declared in legacy `skill.json`. Unused for SKILL.md skills. + #[serde(default)] + pub prompts: Vec, + /// Path to the `SKILL.md` (or `skill.json`) file. + pub location: Option, + /// Full parsed frontmatter when sourced from `SKILL.md`. + #[serde(default)] + pub frontmatter: SkillFrontmatter, + /// Bundled resource files (relative to the skill directory). + #[serde(default)] + pub resources: Vec, + /// Where the skill came from. + #[serde(default)] + pub scope: SkillScope, + /// True when loaded from the legacy `skill.json` / `/skills/` layout. + #[serde(default)] + pub legacy: bool, + /// Non-fatal parse warnings, surfaced in the catalog for user debugging. + #[serde(default)] + pub warnings: Vec, +} + +/// Internal structure for parsing legacy `skill.json` manifests. +#[derive(Debug, Deserialize)] +pub(crate) struct LegacySkillManifest { + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub author: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub prompts: Vec, +} diff --git a/src/openhuman/tools/impl/browser/action_parser.rs b/src/openhuman/tools/impl/browser/action_parser.rs new file mode 100644 index 000000000..5c6cad79a --- /dev/null +++ b/src/openhuman/tools/impl/browser/action_parser.rs @@ -0,0 +1,211 @@ +use super::types::{BrowserAction, ResolvedBackend}; +use serde_json::Value; + +/// Parse a JSON `args` object into a typed `BrowserAction`. +pub(crate) fn parse_browser_action( + action_str: &str, + args: &Value, +) -> anyhow::Result { + match action_str { + "open" => { + let url = args + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'url' for open action"))?; + Ok(BrowserAction::Open { url: url.into() }) + } + "snapshot" => Ok(BrowserAction::Snapshot { + interactive_only: args + .get("interactive_only") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + compact: args + .get("compact") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + depth: args + .get("depth") + .and_then(serde_json::Value::as_u64) + .map(|d| u32::try_from(d).unwrap_or(u32::MAX)), + }), + "click" => { + let selector = args + .get("selector") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for click"))?; + Ok(BrowserAction::Click { + selector: selector.into(), + }) + } + "fill" => { + let selector = args + .get("selector") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for fill"))?; + let value = args + .get("value") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' for fill"))?; + Ok(BrowserAction::Fill { + selector: selector.into(), + value: value.into(), + }) + } + "type" => { + let selector = args + .get("selector") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for type"))?; + let text = args + .get("text") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'text' for type"))?; + Ok(BrowserAction::Type { + selector: selector.into(), + text: text.into(), + }) + } + "get_text" => { + let selector = args + .get("selector") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for get_text"))?; + Ok(BrowserAction::GetText { + selector: selector.into(), + }) + } + "get_title" => Ok(BrowserAction::GetTitle), + "get_url" => Ok(BrowserAction::GetUrl), + "screenshot" => Ok(BrowserAction::Screenshot { + path: args.get("path").and_then(|v| v.as_str()).map(String::from), + full_page: args + .get("full_page") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + }), + "wait" => Ok(BrowserAction::Wait { + selector: args + .get("selector") + .and_then(|v| v.as_str()) + .map(String::from), + ms: args.get("ms").and_then(serde_json::Value::as_u64), + text: args.get("text").and_then(|v| v.as_str()).map(String::from), + }), + "press" => { + let key = args + .get("key") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'key' for press"))?; + Ok(BrowserAction::Press { key: key.into() }) + } + "hover" => { + let selector = args + .get("selector") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for hover"))?; + Ok(BrowserAction::Hover { + selector: selector.into(), + }) + } + "scroll" => { + let direction = args + .get("direction") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'direction' for scroll"))?; + Ok(BrowserAction::Scroll { + direction: direction.into(), + pixels: args + .get("pixels") + .and_then(serde_json::Value::as_u64) + .map(|p| u32::try_from(p).unwrap_or(u32::MAX)), + }) + } + "is_visible" => { + let selector = args + .get("selector") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for is_visible"))?; + Ok(BrowserAction::IsVisible { + selector: selector.into(), + }) + } + "close" => Ok(BrowserAction::Close), + "find" => { + let by = args + .get("by") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'by' for find"))?; + let value = args + .get("value") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' for find"))?; + let action = args + .get("find_action") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'find_action' for find"))?; + Ok(BrowserAction::Find { + by: by.into(), + value: value.into(), + action: action.into(), + fill_value: args + .get("fill_value") + .and_then(|v| v.as_str()) + .map(String::from), + }) + } + other => anyhow::bail!("Unsupported browser action: {other}"), + } +} + +pub(crate) fn is_supported_browser_action(action: &str) -> bool { + matches!( + action, + "open" + | "snapshot" + | "click" + | "fill" + | "type" + | "get_text" + | "get_title" + | "get_url" + | "screenshot" + | "wait" + | "press" + | "hover" + | "scroll" + | "is_visible" + | "close" + | "find" + | "mouse_move" + | "mouse_click" + | "mouse_drag" + | "key_type" + | "key_press" + | "screen_capture" + ) +} + +pub(crate) fn is_computer_use_only_action(action: &str) -> bool { + matches!( + action, + "mouse_move" | "mouse_click" | "mouse_drag" | "key_type" | "key_press" | "screen_capture" + ) +} + +pub(crate) fn backend_name(backend: ResolvedBackend) -> &'static str { + match backend { + ResolvedBackend::AgentBrowser => "agent_browser", + ResolvedBackend::RustNative => "rust_native", + ResolvedBackend::ComputerUse => "computer_use", + } +} + +pub(crate) fn unavailable_action_for_backend_error( + action: &str, + backend: ResolvedBackend, +) -> String { + format!( + "Action '{action}' is unavailable for backend '{}'", + backend_name(backend) + ) +} diff --git a/src/openhuman/tools/impl/browser/browser.rs b/src/openhuman/tools/impl/browser/browser.rs index 4987e1f9c..51e4a6c4f 100644 --- a/src/openhuman/tools/impl/browser/browser.rs +++ b/src/openhuman/tools/impl/browser/browser.rs @@ -5,59 +5,40 @@ //! `--features browser-native` and selected through config. //! Computer-use (OS-level) actions are supported via an optional sidecar endpoint. +#[path = "action_parser.rs"] +mod action_parser; +#[cfg(feature = "browser-native")] +#[path = "native_backend.rs"] +mod native_backend; +#[path = "security.rs"] +mod security; +#[path = "types.rs"] +mod types; + +pub(super) use action_parser::{ + backend_name, is_computer_use_only_action, is_supported_browser_action, parse_browser_action, + unavailable_action_for_backend_error, +}; +pub(super) use security::{ + allow_all_browser_domains, endpoint_reachable, extract_host, host_matches_allowlist, + is_private_host, normalize_domains, +}; +pub(super) use types::{ + AgentBrowserResponse, BrowserBackendKind, ComputerUseResponse, ResolvedBackend, +}; +pub use types::{BrowserAction, ComputerUseConfig}; + use crate::openhuman::security::SecurityPolicy; use crate::openhuman::tools::traits::{Tool, ToolResult}; use anyhow::Context; use async_trait::async_trait; -use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::net::ToSocketAddrs; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; use tokio::process::Command; use tracing::debug; -/// Computer-use sidecar settings. -#[derive(Clone)] -pub struct ComputerUseConfig { - pub endpoint: String, - pub api_key: Option, - pub timeout_ms: u64, - pub allow_remote_endpoint: bool, - pub window_allowlist: Vec, - pub max_coordinate_x: Option, - pub max_coordinate_y: Option, -} - -impl std::fmt::Debug for ComputerUseConfig { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ComputerUseConfig") - .field("endpoint", &self.endpoint) - .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]")) - .field("timeout_ms", &self.timeout_ms) - .field("allow_remote_endpoint", &self.allow_remote_endpoint) - .field("window_allowlist", &self.window_allowlist) - .field("max_coordinate_x", &self.max_coordinate_x) - .field("max_coordinate_y", &self.max_coordinate_y) - .finish() - } -} - -impl Default for ComputerUseConfig { - fn default() -> Self { - Self { - endpoint: "http://127.0.0.1:8787/v1/actions".into(), - api_key: None, - timeout_ms: 15_000, - allow_remote_endpoint: false, - window_allowlist: Vec::new(), - max_coordinate_x: None, - max_coordinate_y: None, - } - } -} - /// Browser automation tool using pluggable backends. pub struct BrowserTool { security: Arc, @@ -72,131 +53,6 @@ pub struct BrowserTool { native_state: tokio::sync::Mutex, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum BrowserBackendKind { - AgentBrowser, - RustNative, - ComputerUse, - Auto, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResolvedBackend { - AgentBrowser, - RustNative, - ComputerUse, -} - -impl BrowserBackendKind { - fn parse(raw: &str) -> anyhow::Result { - let key = raw.trim().to_ascii_lowercase().replace('-', "_"); - match key.as_str() { - "agent_browser" | "agentbrowser" => Ok(Self::AgentBrowser), - "rust_native" | "native" => Ok(Self::RustNative), - "computer_use" | "computeruse" => Ok(Self::ComputerUse), - "auto" => Ok(Self::Auto), - _ => anyhow::bail!( - "Unsupported browser backend '{raw}'. Use 'agent_browser', 'rust_native', 'computer_use', or 'auto'" - ), - } - } - - fn as_str(self) -> &'static str { - match self { - Self::AgentBrowser => "agent_browser", - Self::RustNative => "rust_native", - Self::ComputerUse => "computer_use", - Self::Auto => "auto", - } - } -} - -/// Response from agent-browser --json commands -#[derive(Debug, Deserialize)] -struct AgentBrowserResponse { - success: bool, - data: Option, - error: Option, -} - -/// Response format from computer-use sidecar. -#[derive(Debug, Deserialize)] -struct ComputerUseResponse { - #[serde(default)] - success: Option, - #[serde(default)] - data: Option, - #[serde(default)] - error: Option, -} - -/// Supported browser actions -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BrowserAction { - /// Navigate to a URL - Open { url: String }, - /// Get accessibility snapshot with refs - Snapshot { - #[serde(default)] - interactive_only: bool, - #[serde(default)] - compact: bool, - #[serde(default)] - depth: Option, - }, - /// Click an element by ref or selector - Click { selector: String }, - /// Fill a form field - Fill { selector: String, value: String }, - /// Type text into focused element - Type { selector: String, text: String }, - /// Get text content of element - GetText { selector: String }, - /// Get page title - GetTitle, - /// Get current URL - GetUrl, - /// Take screenshot - Screenshot { - #[serde(default)] - path: Option, - #[serde(default)] - full_page: bool, - }, - /// Wait for element or time - Wait { - #[serde(default)] - selector: Option, - #[serde(default)] - ms: Option, - #[serde(default)] - text: Option, - }, - /// Press a key - Press { key: String }, - /// Hover over element - Hover { selector: String }, - /// Scroll page - Scroll { - direction: String, - #[serde(default)] - pixels: Option, - }, - /// Check if element is visible - IsVisible { selector: String }, - /// Close browser - Close, - /// Find element by semantic locator - Find { - by: String, // role, text, label, placeholder, testid - value: String, - action: String, // click, fill, text, hover - #[serde(default)] - fill_value: Option, - }, -} - impl BrowserTool { pub fn new( security: Arc, @@ -1020,1035 +876,6 @@ impl Tool for BrowserTool { } } -#[cfg(feature = "browser-native")] -mod native_backend { - use super::BrowserAction; - use anyhow::{Context, Result}; - use base64::Engine; - use fantoccini::actions::{InputSource, MouseActions, PointerAction}; - use fantoccini::key::Key; - use fantoccini::{Client, ClientBuilder, Locator}; - use serde_json::{json, Map, Value}; - use std::net::{TcpStream, ToSocketAddrs}; - use std::time::Duration; - - #[derive(Default)] - pub struct NativeBrowserState { - client: Option, - } - - impl NativeBrowserState { - pub fn is_available( - _headless: bool, - webdriver_url: &str, - _chrome_path: Option<&str>, - ) -> bool { - webdriver_endpoint_reachable(webdriver_url, Duration::from_millis(500)) - } - - #[allow(clippy::too_many_lines)] - pub async fn execute_action( - &mut self, - action: BrowserAction, - headless: bool, - webdriver_url: &str, - chrome_path: Option<&str>, - ) -> Result { - match action { - BrowserAction::Open { url } => { - self.ensure_session(headless, webdriver_url, chrome_path) - .await?; - let client = self.active_client()?; - client - .goto(&url) - .await - .with_context(|| format!("Failed to open URL: {url}"))?; - let current_url = client - .current_url() - .await - .context("Failed to read current URL after navigation")?; - - Ok(json!({ - "backend": "rust_native", - "action": "open", - "url": current_url.as_str(), - })) - } - BrowserAction::Snapshot { - interactive_only, - compact, - depth, - } => { - let client = self.active_client()?; - let snapshot = client - .execute( - &snapshot_script(interactive_only, compact, depth.map(i64::from)), - vec![], - ) - .await - .context("Failed to evaluate snapshot script")?; - - Ok(json!({ - "backend": "rust_native", - "action": "snapshot", - "data": snapshot, - })) - } - BrowserAction::Click { selector } => { - let client = self.active_client()?; - find_element(client, &selector).await?.click().await?; - - Ok(json!({ - "backend": "rust_native", - "action": "click", - "selector": selector, - })) - } - BrowserAction::Fill { selector, value } => { - let client = self.active_client()?; - let element = find_element(client, &selector).await?; - let _ = element.clear().await; - element.send_keys(&value).await?; - - Ok(json!({ - "backend": "rust_native", - "action": "fill", - "selector": selector, - })) - } - BrowserAction::Type { selector, text } => { - let client = self.active_client()?; - find_element(client, &selector) - .await? - .send_keys(&text) - .await?; - - Ok(json!({ - "backend": "rust_native", - "action": "type", - "selector": selector, - "typed": text.len(), - })) - } - BrowserAction::GetText { selector } => { - let client = self.active_client()?; - let text = find_element(client, &selector).await?.text().await?; - - Ok(json!({ - "backend": "rust_native", - "action": "get_text", - "selector": selector, - "text": text, - })) - } - BrowserAction::GetTitle => { - let client = self.active_client()?; - let title = client.title().await.context("Failed to read page title")?; - - Ok(json!({ - "backend": "rust_native", - "action": "get_title", - "title": title, - })) - } - BrowserAction::GetUrl => { - let client = self.active_client()?; - let url = client - .current_url() - .await - .context("Failed to read current URL")?; - - Ok(json!({ - "backend": "rust_native", - "action": "get_url", - "url": url.as_str(), - })) - } - BrowserAction::Screenshot { path, full_page } => { - let client = self.active_client()?; - let png = client - .screenshot() - .await - .context("Failed to capture screenshot")?; - let mut payload = json!({ - "backend": "rust_native", - "action": "screenshot", - "full_page": full_page, - "bytes": png.len(), - }); - - if let Some(path_str) = path { - tokio::fs::write(&path_str, &png) - .await - .with_context(|| format!("Failed to write screenshot to {path_str}"))?; - payload["path"] = Value::String(path_str); - } else { - payload["png_base64"] = - Value::String(base64::engine::general_purpose::STANDARD.encode(&png)); - } - - Ok(payload) - } - BrowserAction::Wait { selector, ms, text } => { - let client = self.active_client()?; - if let Some(sel) = selector.as_ref() { - wait_for_selector(client, sel).await?; - Ok(json!({ - "backend": "rust_native", - "action": "wait", - "selector": sel, - })) - } else if let Some(duration_ms) = ms { - tokio::time::sleep(Duration::from_millis(duration_ms)).await; - Ok(json!({ - "backend": "rust_native", - "action": "wait", - "ms": duration_ms, - })) - } else if let Some(needle) = text.as_ref() { - let xpath = xpath_contains_text(needle); - client - .wait() - .for_element(Locator::XPath(&xpath)) - .await - .with_context(|| { - format!("Timed out waiting for text to appear: {needle}") - })?; - Ok(json!({ - "backend": "rust_native", - "action": "wait", - "text": needle, - })) - } else { - tokio::time::sleep(Duration::from_millis(250)).await; - Ok(json!({ - "backend": "rust_native", - "action": "wait", - "ms": 250, - })) - } - } - BrowserAction::Press { key } => { - let client = self.active_client()?; - let key_input = webdriver_key(&key); - match client.active_element().await { - Ok(element) => { - element.send_keys(&key_input).await?; - } - Err(_) => { - find_element(client, "body") - .await? - .send_keys(&key_input) - .await?; - } - } - - Ok(json!({ - "backend": "rust_native", - "action": "press", - "key": key, - })) - } - BrowserAction::Hover { selector } => { - let client = self.active_client()?; - let element = find_element(client, &selector).await?; - hover_element(client, &element).await?; - - Ok(json!({ - "backend": "rust_native", - "action": "hover", - "selector": selector, - })) - } - BrowserAction::Scroll { direction, pixels } => { - let client = self.active_client()?; - let amount = i64::from(pixels.unwrap_or(600)); - let (dx, dy) = match direction.as_str() { - "up" => (0, -amount), - "down" => (0, amount), - "left" => (-amount, 0), - "right" => (amount, 0), - _ => anyhow::bail!( - "Unsupported scroll direction '{direction}'. Use up/down/left/right" - ), - }; - - let position = client - .execute( - "window.scrollBy(arguments[0], arguments[1]); return { x: window.scrollX, y: window.scrollY };", - vec![json!(dx), json!(dy)], - ) - .await - .context("Failed to execute scroll script")?; - - Ok(json!({ - "backend": "rust_native", - "action": "scroll", - "position": position, - })) - } - BrowserAction::IsVisible { selector } => { - let client = self.active_client()?; - let visible = find_element(client, &selector) - .await? - .is_displayed() - .await?; - - Ok(json!({ - "backend": "rust_native", - "action": "is_visible", - "selector": selector, - "visible": visible, - })) - } - BrowserAction::Close => { - if let Some(client) = self.client.take() { - let _ = client.close().await; - } - - Ok(json!({ - "backend": "rust_native", - "action": "close", - "closed": true, - })) - } - BrowserAction::Find { - by, - value, - action, - fill_value, - } => { - let client = self.active_client()?; - let selector = selector_for_find(&by, &value); - let element = find_element(client, &selector).await?; - - let payload = match action.as_str() { - "click" => { - element.click().await?; - json!({"result": "clicked"}) - } - "fill" => { - let fill = fill_value.ok_or_else(|| { - anyhow::anyhow!("find_action='fill' requires fill_value") - })?; - let _ = element.clear().await; - element.send_keys(&fill).await?; - json!({"result": "filled", "typed": fill.len()}) - } - "text" => { - let text = element.text().await?; - json!({"result": "text", "text": text}) - } - "hover" => { - hover_element(client, &element).await?; - json!({"result": "hovered"}) - } - "check" => { - let checked_before = element_checked(&element).await?; - if !checked_before { - element.click().await?; - } - let checked_after = element_checked(&element).await?; - json!({ - "result": "checked", - "checked_before": checked_before, - "checked_after": checked_after, - }) - } - _ => anyhow::bail!( - "Unsupported find_action '{action}'. Use click/fill/text/hover/check" - ), - }; - - Ok(json!({ - "backend": "rust_native", - "action": "find", - "by": by, - "value": value, - "selector": selector, - "data": payload, - })) - } - } - } - - async fn ensure_session( - &mut self, - headless: bool, - webdriver_url: &str, - chrome_path: Option<&str>, - ) -> Result<()> { - if self.client.is_some() { - return Ok(()); - } - - let mut capabilities: Map = Map::new(); - let mut chrome_options: Map = Map::new(); - let mut args: Vec = Vec::new(); - - if headless { - args.push(Value::String("--headless=new".to_string())); - args.push(Value::String("--disable-gpu".to_string())); - } - - if !args.is_empty() { - chrome_options.insert("args".to_string(), Value::Array(args)); - } - - if let Some(path) = chrome_path { - let trimmed = path.trim(); - if !trimmed.is_empty() { - chrome_options.insert("binary".to_string(), Value::String(trimmed.to_string())); - } - } - - if !chrome_options.is_empty() { - capabilities.insert( - "goog:chromeOptions".to_string(), - Value::Object(chrome_options), - ); - } - - let mut builder = - ClientBuilder::rustls().context("Failed to initialize rustls connector")?; - if !capabilities.is_empty() { - builder.capabilities(capabilities); - } - - let client = builder - .connect(webdriver_url) - .await - .with_context(|| { - format!( - "Failed to connect to WebDriver at {webdriver_url}. Start chromedriver/geckodriver first" - ) - })?; - - self.client = Some(client); - Ok(()) - } - - fn active_client(&self) -> Result<&Client> { - self.client.as_ref().ok_or_else(|| { - anyhow::anyhow!("No active native browser session. Run browser action='open' first") - }) - } - } - - fn webdriver_endpoint_reachable(webdriver_url: &str, timeout: Duration) -> bool { - let parsed = match reqwest::Url::parse(webdriver_url) { - Ok(url) => url, - Err(_) => return false, - }; - - if parsed.scheme() != "http" && parsed.scheme() != "https" { - return false; - } - - let host = match parsed.host_str() { - Some(h) if !h.is_empty() => h, - _ => return false, - }; - - let port = parsed.port_or_known_default().unwrap_or(4444); - let mut addrs = match (host, port).to_socket_addrs() { - Ok(iter) => iter, - Err(_) => return false, - }; - - let addr = match addrs.next() { - Some(a) => a, - None => return false, - }; - - TcpStream::connect_timeout(&addr, timeout).is_ok() - } - - fn selector_for_find(by: &str, value: &str) -> String { - let escaped = css_attr_escape(value); - match by { - "role" => format!(r#"[role=\"{escaped}\"]"#), - "label" => format!("label={value}"), - "placeholder" => format!(r#"[placeholder=\"{escaped}\"]"#), - "testid" => format!(r#"[data-testid=\"{escaped}\"]"#), - _ => format!("text={value}"), - } - } - - async fn wait_for_selector(client: &Client, selector: &str) -> Result<()> { - match parse_selector(selector) { - SelectorKind::Css(css) => { - client - .wait() - .for_element(Locator::Css(&css)) - .await - .with_context(|| format!("Timed out waiting for selector '{selector}'"))?; - } - SelectorKind::XPath(xpath) => { - client - .wait() - .for_element(Locator::XPath(&xpath)) - .await - .with_context(|| format!("Timed out waiting for selector '{selector}'"))?; - } - } - Ok(()) - } - - async fn find_element( - client: &Client, - selector: &str, - ) -> Result { - let element = match parse_selector(selector) { - SelectorKind::Css(css) => client - .find(Locator::Css(&css)) - .await - .with_context(|| format!("Failed to find element by CSS '{css}'"))?, - SelectorKind::XPath(xpath) => client - .find(Locator::XPath(&xpath)) - .await - .with_context(|| format!("Failed to find element by XPath '{xpath}'"))?, - }; - Ok(element) - } - - async fn hover_element(client: &Client, element: &fantoccini::elements::Element) -> Result<()> { - let actions = MouseActions::new("mouse".to_string()).then(PointerAction::MoveToElement { - element: element.clone(), - duration: Some(Duration::from_millis(150)), - x: 0.0, - y: 0.0, - }); - - client - .perform_actions(actions) - .await - .context("Failed to perform hover action")?; - let _ = client.release_actions().await; - Ok(()) - } - - async fn element_checked(element: &fantoccini::elements::Element) -> Result { - let checked = element - .prop("checked") - .await - .context("Failed to read checkbox checked property")? - .unwrap_or_default() - .to_ascii_lowercase(); - Ok(matches!(checked.as_str(), "true" | "checked" | "1")) - } - - enum SelectorKind { - Css(String), - XPath(String), - } - - fn parse_selector(selector: &str) -> SelectorKind { - let trimmed = selector.trim(); - if let Some(text_query) = trimmed.strip_prefix("text=") { - return SelectorKind::XPath(xpath_contains_text(text_query)); - } - - if let Some(label_query) = trimmed.strip_prefix("label=") { - let literal = xpath_literal(label_query); - return SelectorKind::XPath(format!( - "(//label[contains(normalize-space(.), {literal})]/following::*[self::input or self::textarea or self::select][1] | //*[@aria-label and contains(normalize-space(@aria-label), {literal})] | //label[contains(normalize-space(.), {literal})])" - )); - } - - if trimmed.starts_with('@') { - let escaped = css_attr_escape(trimmed); - return SelectorKind::Css(format!(r#"[data-zc-ref=\"{escaped}\"]"#)); - } - - SelectorKind::Css(trimmed.to_string()) - } - - fn css_attr_escape(input: &str) -> String { - input - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', " ") - } - - fn xpath_contains_text(text: &str) -> String { - format!("//*[contains(normalize-space(.), {})]", xpath_literal(text)) - } - - fn xpath_literal(input: &str) -> String { - if !input.contains('"') { - return format!("\"{input}\""); - } - if !input.contains('\'') { - return format!("'{input}'"); - } - - let segments: Vec<&str> = input.split('"').collect(); - let mut parts: Vec = Vec::new(); - for (index, part) in segments.iter().enumerate() { - if !part.is_empty() { - parts.push(format!("\"{part}\"")); - } - if index + 1 < segments.len() { - parts.push("'\"'".to_string()); - } - } - - if parts.is_empty() { - "\"\"".to_string() - } else { - format!("concat({})", parts.join(",")) - } - } - - fn webdriver_key(key: &str) -> String { - match key.trim().to_ascii_lowercase().as_str() { - "enter" => Key::Enter.to_string(), - "return" => Key::Return.to_string(), - "tab" => Key::Tab.to_string(), - "escape" | "esc" => Key::Escape.to_string(), - "backspace" => Key::Backspace.to_string(), - "delete" => Key::Delete.to_string(), - "space" => Key::Space.to_string(), - "arrowup" | "up" => Key::Up.to_string(), - "arrowdown" | "down" => Key::Down.to_string(), - "arrowleft" | "left" => Key::Left.to_string(), - "arrowright" | "right" => Key::Right.to_string(), - "home" => Key::Home.to_string(), - "end" => Key::End.to_string(), - "pageup" => Key::PageUp.to_string(), - "pagedown" => Key::PageDown.to_string(), - other => other.to_string(), - } - } - - fn snapshot_script(interactive_only: bool, compact: bool, depth: Option) -> String { - let depth_literal = depth - .map(|level| level.to_string()) - .unwrap_or_else(|| "null".to_string()); - - format!( - r#"(() => {{ - const interactiveOnly = {interactive_only}; - const compact = {compact}; - const maxDepth = {depth_literal}; - const nodes = []; - const root = document.body || document.documentElement; - let counter = 0; - - const isVisible = (el) => {{ - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || 1) === 0) {{ - return false; - }} - const rect = el.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }}; - - const isInteractive = (el) => {{ - if (el.matches('a,button,input,select,textarea,summary,[role],*[tabindex]')) return true; - return typeof el.onclick === 'function'; - }}; - - const describe = (el, depth) => {{ - const interactive = isInteractive(el); - const text = (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 140); - if (interactiveOnly && !interactive) return; - if (compact && !interactive && !text) return; - - const ref = '@e' + (++counter); - el.setAttribute('data-zc-ref', ref); - nodes.push({{ - ref, - depth, - tag: el.tagName.toLowerCase(), - id: el.id || null, - role: el.getAttribute('role'), - text, - interactive, - }}); - }}; - - const walk = (el, depth) => {{ - if (!(el instanceof Element)) return; - if (maxDepth !== null && depth > maxDepth) return; - if (isVisible(el)) {{ - describe(el, depth); - }} - for (const child of el.children) {{ - walk(child, depth + 1); - if (nodes.length >= 400) return; - }} - }}; - - if (root) walk(root, 0); - - return {{ - title: document.title, - url: window.location.href, - count: nodes.length, - nodes, - }}; -}})();"# - ) - } -} - -// ── Action parsing ────────────────────────────────────────────── - -/// Parse a JSON `args` object into a typed `BrowserAction`. -fn parse_browser_action(action_str: &str, args: &Value) -> anyhow::Result { - match action_str { - "open" => { - let url = args - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'url' for open action"))?; - Ok(BrowserAction::Open { url: url.into() }) - } - "snapshot" => Ok(BrowserAction::Snapshot { - interactive_only: args - .get("interactive_only") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true), - compact: args - .get("compact") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true), - depth: args - .get("depth") - .and_then(serde_json::Value::as_u64) - .map(|d| u32::try_from(d).unwrap_or(u32::MAX)), - }), - "click" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for click"))?; - Ok(BrowserAction::Click { - selector: selector.into(), - }) - } - "fill" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for fill"))?; - let value = args - .get("value") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'value' for fill"))?; - Ok(BrowserAction::Fill { - selector: selector.into(), - value: value.into(), - }) - } - "type" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for type"))?; - let text = args - .get("text") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'text' for type"))?; - Ok(BrowserAction::Type { - selector: selector.into(), - text: text.into(), - }) - } - "get_text" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for get_text"))?; - Ok(BrowserAction::GetText { - selector: selector.into(), - }) - } - "get_title" => Ok(BrowserAction::GetTitle), - "get_url" => Ok(BrowserAction::GetUrl), - "screenshot" => Ok(BrowserAction::Screenshot { - path: args.get("path").and_then(|v| v.as_str()).map(String::from), - full_page: args - .get("full_page") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), - }), - "wait" => Ok(BrowserAction::Wait { - selector: args - .get("selector") - .and_then(|v| v.as_str()) - .map(String::from), - ms: args.get("ms").and_then(serde_json::Value::as_u64), - text: args.get("text").and_then(|v| v.as_str()).map(String::from), - }), - "press" => { - let key = args - .get("key") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'key' for press"))?; - Ok(BrowserAction::Press { key: key.into() }) - } - "hover" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for hover"))?; - Ok(BrowserAction::Hover { - selector: selector.into(), - }) - } - "scroll" => { - let direction = args - .get("direction") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'direction' for scroll"))?; - Ok(BrowserAction::Scroll { - direction: direction.into(), - pixels: args - .get("pixels") - .and_then(serde_json::Value::as_u64) - .map(|p| u32::try_from(p).unwrap_or(u32::MAX)), - }) - } - "is_visible" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for is_visible"))?; - Ok(BrowserAction::IsVisible { - selector: selector.into(), - }) - } - "close" => Ok(BrowserAction::Close), - "find" => { - let by = args - .get("by") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'by' for find"))?; - let value = args - .get("value") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'value' for find"))?; - let action = args - .get("find_action") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'find_action' for find"))?; - Ok(BrowserAction::Find { - by: by.into(), - value: value.into(), - action: action.into(), - fill_value: args - .get("fill_value") - .and_then(|v| v.as_str()) - .map(String::from), - }) - } - other => anyhow::bail!("Unsupported browser action: {other}"), - } -} - -// ── Helper functions ───────────────────────────────────────────── - -fn is_supported_browser_action(action: &str) -> bool { - matches!( - action, - "open" - | "snapshot" - | "click" - | "fill" - | "type" - | "get_text" - | "get_title" - | "get_url" - | "screenshot" - | "wait" - | "press" - | "hover" - | "scroll" - | "is_visible" - | "close" - | "find" - | "mouse_move" - | "mouse_click" - | "mouse_drag" - | "key_type" - | "key_press" - | "screen_capture" - ) -} - -fn is_computer_use_only_action(action: &str) -> bool { - matches!( - action, - "mouse_move" | "mouse_click" | "mouse_drag" | "key_type" | "key_press" | "screen_capture" - ) -} - -fn backend_name(backend: ResolvedBackend) -> &'static str { - match backend { - ResolvedBackend::AgentBrowser => "agent_browser", - ResolvedBackend::RustNative => "rust_native", - ResolvedBackend::ComputerUse => "computer_use", - } -} - -fn unavailable_action_for_backend_error(action: &str, backend: ResolvedBackend) -> String { - format!( - "Action '{action}' is unavailable for backend '{}'", - backend_name(backend) - ) -} - -fn normalize_domains(domains: Vec) -> Vec { - domains - .into_iter() - .map(|d| d.trim().to_lowercase()) - .filter(|d| !d.is_empty()) - .collect() -} - -fn endpoint_reachable(endpoint: &reqwest::Url, timeout: Duration) -> bool { - let host = match endpoint.host_str() { - Some(host) if !host.is_empty() => host, - _ => return false, - }; - - let port = match endpoint.port_or_known_default() { - Some(port) => port, - None => return false, - }; - - let mut addrs = match (host, port).to_socket_addrs() { - Ok(addrs) => addrs, - Err(_) => return false, - }; - - let addr = match addrs.next() { - Some(addr) => addr, - None => return false, - }; - - std::net::TcpStream::connect_timeout(&addr, timeout).is_ok() -} - -fn extract_host(url_str: &str) -> anyhow::Result { - // Simple host extraction without url crate - let url = url_str.trim(); - let without_scheme = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://")) - .or_else(|| url.strip_prefix("file://")) - .unwrap_or(url); - - // Extract host — handle bracketed IPv6 addresses like [::1]:8080 - let authority = without_scheme.split('/').next().unwrap_or(without_scheme); - - let host = if authority.starts_with('[') { - // IPv6: take everything up to and including the closing ']' - authority.find(']').map_or(authority, |i| &authority[..=i]) - } else { - // IPv4 or hostname: take everything before the port separator - authority.split(':').next().unwrap_or(authority) - }; - - if host.is_empty() { - anyhow::bail!("Invalid URL: no host"); - } - - Ok(host.to_lowercase()) -} - -fn is_private_host(host: &str) -> bool { - // Strip brackets from IPv6 addresses like [::1] - let bare = host - .strip_prefix('[') - .and_then(|h| h.strip_suffix(']')) - .unwrap_or(host); - - if bare == "localhost" || bare.ends_with(".localhost") { - return true; - } - - // .local TLD (mDNS) - if bare - .rsplit('.') - .next() - .is_some_and(|label| label == "local") - { - return true; - } - - // Parse as IP address to catch all representations (decimal, hex, octal, mapped) - if let Ok(ip) = bare.parse::() { - return match ip { - std::net::IpAddr::V4(v4) => is_non_global_v4(v4), - std::net::IpAddr::V6(v6) => is_non_global_v6(v6), - }; - } - - false -} - -/// Returns `true` for any IPv4 address that is not globally routable. -fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { - let [a, b, _, _] = v4.octets(); - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_unspecified() - || v4.is_broadcast() - || v4.is_multicast() - // Shared address space (100.64/10) - || (a == 100 && (64..=127).contains(&b)) - // Reserved (240.0.0.0/4) - || a >= 240 - // Documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) - || (a == 192 && b == 0) - || (a == 198 && b == 51) - || (a == 203 && b == 0) - // Benchmarking (198.18.0.0/15) - || (a == 198 && (18..=19).contains(&b)) -} - -/// Returns `true` for any IPv6 address that is not globally routable. -fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { - let segs = v6.segments(); - v6.is_loopback() - || v6.is_unspecified() - || v6.is_multicast() - // Unique-local (fc00::/7) — IPv6 equivalent of RFC 1918 - || (segs[0] & 0xfe00) == 0xfc00 - // Link-local (fe80::/10) - || (segs[0] & 0xffc0) == 0xfe80 - // IPv4-mapped addresses - || v6.to_ipv4_mapped().is_some_and(is_non_global_v4) -} - -fn allow_all_browser_domains() -> bool { - matches!( - std::env::var("OPENHUMAN_BROWSER_ALLOW_ALL").ok().as_deref(), - Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES") - ) -} - -fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool { - allowed.iter().any(|pattern| { - if pattern == "*" { - return true; - } - if pattern.starts_with("*.") { - // Wildcard subdomain match - let suffix = &pattern[1..]; // ".example.com" - host.ends_with(suffix) || host == &pattern[2..] - } else { - // Exact match or subdomain - host == pattern || host.ends_with(&format!(".{pattern}")) - } - }) -} - #[cfg(test)] #[path = "browser_tests.rs"] mod tests; diff --git a/src/openhuman/tools/impl/browser/native_backend.rs b/src/openhuman/tools/impl/browser/native_backend.rs new file mode 100644 index 000000000..f81f1ec96 --- /dev/null +++ b/src/openhuman/tools/impl/browser/native_backend.rs @@ -0,0 +1,663 @@ +use super::BrowserAction; +use anyhow::{Context, Result}; +use base64::Engine; +use fantoccini::actions::{InputSource, MouseActions, PointerAction}; +use fantoccini::key::Key; +use fantoccini::{Client, ClientBuilder, Locator}; +use serde_json::{json, Map, Value}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +#[derive(Default)] +pub struct NativeBrowserState { + client: Option, +} + +impl NativeBrowserState { + pub fn is_available(_headless: bool, webdriver_url: &str, _chrome_path: Option<&str>) -> bool { + webdriver_endpoint_reachable(webdriver_url, Duration::from_millis(500)) + } + + #[allow(clippy::too_many_lines)] + pub async fn execute_action( + &mut self, + action: BrowserAction, + headless: bool, + webdriver_url: &str, + chrome_path: Option<&str>, + ) -> Result { + match action { + BrowserAction::Open { url } => { + self.ensure_session(headless, webdriver_url, chrome_path) + .await?; + let client = self.active_client()?; + client + .goto(&url) + .await + .with_context(|| format!("Failed to open URL: {url}"))?; + let current_url = client + .current_url() + .await + .context("Failed to read current URL after navigation")?; + + Ok(json!({ + "backend": "rust_native", + "action": "open", + "url": current_url.as_str(), + })) + } + BrowserAction::Snapshot { + interactive_only, + compact, + depth, + } => { + let client = self.active_client()?; + let snapshot = client + .execute( + &snapshot_script(interactive_only, compact, depth.map(i64::from)), + vec![], + ) + .await + .context("Failed to evaluate snapshot script")?; + + Ok(json!({ + "backend": "rust_native", + "action": "snapshot", + "data": snapshot, + })) + } + BrowserAction::Click { selector } => { + let client = self.active_client()?; + find_element(client, &selector).await?.click().await?; + + Ok(json!({ + "backend": "rust_native", + "action": "click", + "selector": selector, + })) + } + BrowserAction::Fill { selector, value } => { + let client = self.active_client()?; + let element = find_element(client, &selector).await?; + let _ = element.clear().await; + element.send_keys(&value).await?; + + Ok(json!({ + "backend": "rust_native", + "action": "fill", + "selector": selector, + })) + } + BrowserAction::Type { selector, text } => { + let client = self.active_client()?; + find_element(client, &selector) + .await? + .send_keys(&text) + .await?; + + Ok(json!({ + "backend": "rust_native", + "action": "type", + "selector": selector, + "typed": text.len(), + })) + } + BrowserAction::GetText { selector } => { + let client = self.active_client()?; + let text = find_element(client, &selector).await?.text().await?; + + Ok(json!({ + "backend": "rust_native", + "action": "get_text", + "selector": selector, + "text": text, + })) + } + BrowserAction::GetTitle => { + let client = self.active_client()?; + let title = client.title().await.context("Failed to read page title")?; + + Ok(json!({ + "backend": "rust_native", + "action": "get_title", + "title": title, + })) + } + BrowserAction::GetUrl => { + let client = self.active_client()?; + let url = client + .current_url() + .await + .context("Failed to read current URL")?; + + Ok(json!({ + "backend": "rust_native", + "action": "get_url", + "url": url.as_str(), + })) + } + BrowserAction::Screenshot { path, full_page } => { + let client = self.active_client()?; + let png = client + .screenshot() + .await + .context("Failed to capture screenshot")?; + let mut payload = json!({ + "backend": "rust_native", + "action": "screenshot", + "full_page": full_page, + "bytes": png.len(), + }); + + if let Some(path_str) = path { + tokio::fs::write(&path_str, &png) + .await + .with_context(|| format!("Failed to write screenshot to {path_str}"))?; + payload["path"] = Value::String(path_str); + } else { + payload["png_base64"] = + Value::String(base64::engine::general_purpose::STANDARD.encode(&png)); + } + + Ok(payload) + } + BrowserAction::Wait { selector, ms, text } => { + let client = self.active_client()?; + if let Some(sel) = selector.as_ref() { + wait_for_selector(client, sel).await?; + Ok(json!({ + "backend": "rust_native", + "action": "wait", + "selector": sel, + })) + } else if let Some(duration_ms) = ms { + tokio::time::sleep(Duration::from_millis(duration_ms)).await; + Ok(json!({ + "backend": "rust_native", + "action": "wait", + "ms": duration_ms, + })) + } else if let Some(needle) = text.as_ref() { + let xpath = xpath_contains_text(needle); + client + .wait() + .for_element(Locator::XPath(&xpath)) + .await + .with_context(|| { + format!("Timed out waiting for text to appear: {needle}") + })?; + Ok(json!({ + "backend": "rust_native", + "action": "wait", + "text": needle, + })) + } else { + tokio::time::sleep(Duration::from_millis(250)).await; + Ok(json!({ + "backend": "rust_native", + "action": "wait", + "ms": 250, + })) + } + } + BrowserAction::Press { key } => { + let client = self.active_client()?; + let key_input = webdriver_key(&key); + match client.active_element().await { + Ok(element) => { + element.send_keys(&key_input).await?; + } + Err(_) => { + find_element(client, "body") + .await? + .send_keys(&key_input) + .await?; + } + } + + Ok(json!({ + "backend": "rust_native", + "action": "press", + "key": key, + })) + } + BrowserAction::Hover { selector } => { + let client = self.active_client()?; + let element = find_element(client, &selector).await?; + hover_element(client, &element).await?; + + Ok(json!({ + "backend": "rust_native", + "action": "hover", + "selector": selector, + })) + } + BrowserAction::Scroll { direction, pixels } => { + let client = self.active_client()?; + let amount = i64::from(pixels.unwrap_or(600)); + let (dx, dy) = match direction.as_str() { + "up" => (0, -amount), + "down" => (0, amount), + "left" => (-amount, 0), + "right" => (amount, 0), + _ => anyhow::bail!( + "Unsupported scroll direction '{direction}'. Use up/down/left/right" + ), + }; + + let position = client + .execute( + "window.scrollBy(arguments[0], arguments[1]); return { x: window.scrollX, y: window.scrollY };", + vec![json!(dx), json!(dy)], + ) + .await + .context("Failed to execute scroll script")?; + + Ok(json!({ + "backend": "rust_native", + "action": "scroll", + "position": position, + })) + } + BrowserAction::IsVisible { selector } => { + let client = self.active_client()?; + let visible = find_element(client, &selector) + .await? + .is_displayed() + .await?; + + Ok(json!({ + "backend": "rust_native", + "action": "is_visible", + "selector": selector, + "visible": visible, + })) + } + BrowserAction::Close => { + if let Some(client) = self.client.take() { + let _ = client.close().await; + } + + Ok(json!({ + "backend": "rust_native", + "action": "close", + "closed": true, + })) + } + BrowserAction::Find { + by, + value, + action, + fill_value, + } => { + let client = self.active_client()?; + let selector = selector_for_find(&by, &value); + let element = find_element(client, &selector).await?; + + let payload = match action.as_str() { + "click" => { + element.click().await?; + json!({"result": "clicked"}) + } + "fill" => { + let fill = fill_value.ok_or_else(|| { + anyhow::anyhow!("find_action='fill' requires fill_value") + })?; + let _ = element.clear().await; + element.send_keys(&fill).await?; + json!({"result": "filled", "typed": fill.len()}) + } + "text" => { + let text = element.text().await?; + json!({"result": "text", "text": text}) + } + "hover" => { + hover_element(client, &element).await?; + json!({"result": "hovered"}) + } + "check" => { + let checked_before = element_checked(&element).await?; + if !checked_before { + element.click().await?; + } + let checked_after = element_checked(&element).await?; + json!({ + "result": "checked", + "checked_before": checked_before, + "checked_after": checked_after, + }) + } + _ => anyhow::bail!( + "Unsupported find_action '{action}'. Use click/fill/text/hover/check" + ), + }; + + Ok(json!({ + "backend": "rust_native", + "action": "find", + "by": by, + "value": value, + "selector": selector, + "data": payload, + })) + } + } + } + + async fn ensure_session( + &mut self, + headless: bool, + webdriver_url: &str, + chrome_path: Option<&str>, + ) -> Result<()> { + if self.client.is_some() { + return Ok(()); + } + + let mut capabilities: Map = Map::new(); + let mut chrome_options: Map = Map::new(); + let mut args: Vec = Vec::new(); + + if headless { + args.push(Value::String("--headless=new".to_string())); + args.push(Value::String("--disable-gpu".to_string())); + } + + if !args.is_empty() { + chrome_options.insert("args".to_string(), Value::Array(args)); + } + + if let Some(path) = chrome_path { + let trimmed = path.trim(); + if !trimmed.is_empty() { + chrome_options.insert("binary".to_string(), Value::String(trimmed.to_string())); + } + } + + if !chrome_options.is_empty() { + capabilities.insert( + "goog:chromeOptions".to_string(), + Value::Object(chrome_options), + ); + } + + let mut builder = + ClientBuilder::rustls().context("Failed to initialize rustls connector")?; + if !capabilities.is_empty() { + builder.capabilities(capabilities); + } + + let client = builder + .connect(webdriver_url) + .await + .with_context(|| { + format!( + "Failed to connect to WebDriver at {webdriver_url}. Start chromedriver/geckodriver first" + ) + })?; + + self.client = Some(client); + Ok(()) + } + + fn active_client(&self) -> Result<&Client> { + self.client.as_ref().ok_or_else(|| { + anyhow::anyhow!("No active native browser session. Run browser action='open' first") + }) + } +} + +fn webdriver_endpoint_reachable(webdriver_url: &str, timeout: Duration) -> bool { + let parsed = match reqwest::Url::parse(webdriver_url) { + Ok(url) => url, + Err(_) => return false, + }; + + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return false; + } + + let host = match parsed.host_str() { + Some(h) if !h.is_empty() => h, + _ => return false, + }; + + let port = parsed.port_or_known_default().unwrap_or(4444); + let mut addrs = match (host, port).to_socket_addrs() { + Ok(iter) => iter, + Err(_) => return false, + }; + + let addr = match addrs.next() { + Some(a) => a, + None => return false, + }; + + TcpStream::connect_timeout(&addr, timeout).is_ok() +} + +fn selector_for_find(by: &str, value: &str) -> String { + let escaped = css_attr_escape(value); + match by { + "role" => format!(r#"[role=\"{escaped}\"]"#), + "label" => format!("label={value}"), + "placeholder" => format!(r#"[placeholder=\"{escaped}\"]"#), + "testid" => format!(r#"[data-testid=\"{escaped}\"]"#), + _ => format!("text={value}"), + } +} + +async fn wait_for_selector(client: &Client, selector: &str) -> Result<()> { + match parse_selector(selector) { + SelectorKind::Css(css) => { + client + .wait() + .for_element(Locator::Css(&css)) + .await + .with_context(|| format!("Timed out waiting for selector '{selector}'"))?; + } + SelectorKind::XPath(xpath) => { + client + .wait() + .for_element(Locator::XPath(&xpath)) + .await + .with_context(|| format!("Timed out waiting for selector '{selector}'"))?; + } + } + Ok(()) +} + +async fn find_element(client: &Client, selector: &str) -> Result { + let element = match parse_selector(selector) { + SelectorKind::Css(css) => client + .find(Locator::Css(&css)) + .await + .with_context(|| format!("Failed to find element by CSS '{css}'"))?, + SelectorKind::XPath(xpath) => client + .find(Locator::XPath(&xpath)) + .await + .with_context(|| format!("Failed to find element by XPath '{xpath}'"))?, + }; + Ok(element) +} + +async fn hover_element(client: &Client, element: &fantoccini::elements::Element) -> Result<()> { + let actions = MouseActions::new("mouse".to_string()).then(PointerAction::MoveToElement { + element: element.clone(), + duration: Some(Duration::from_millis(150)), + x: 0.0, + y: 0.0, + }); + + client + .perform_actions(actions) + .await + .context("Failed to perform hover action")?; + let _ = client.release_actions().await; + Ok(()) +} + +async fn element_checked(element: &fantoccini::elements::Element) -> Result { + let checked = element + .prop("checked") + .await + .context("Failed to read checkbox checked property")? + .unwrap_or_default() + .to_ascii_lowercase(); + Ok(matches!(checked.as_str(), "true" | "checked" | "1")) +} + +enum SelectorKind { + Css(String), + XPath(String), +} + +fn parse_selector(selector: &str) -> SelectorKind { + let trimmed = selector.trim(); + if let Some(text_query) = trimmed.strip_prefix("text=") { + return SelectorKind::XPath(xpath_contains_text(text_query)); + } + + if let Some(label_query) = trimmed.strip_prefix("label=") { + let literal = xpath_literal(label_query); + return SelectorKind::XPath(format!( + "(//label[contains(normalize-space(.), {literal})]/following::*[self::input or self::textarea or self::select][1] | //*[@aria-label and contains(normalize-space(@aria-label), {literal})] | //label[contains(normalize-space(.), {literal})])" + )); + } + + if trimmed.starts_with('@') { + let escaped = css_attr_escape(trimmed); + return SelectorKind::Css(format!(r#"[data-zc-ref=\"{escaped}\"]"#)); + } + + SelectorKind::Css(trimmed.to_string()) +} + +fn css_attr_escape(input: &str) -> String { + input + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', " ") +} + +fn xpath_contains_text(text: &str) -> String { + format!("//*[contains(normalize-space(.), {})]", xpath_literal(text)) +} + +fn xpath_literal(input: &str) -> String { + if !input.contains('"') { + return format!("\"{input}\""); + } + if !input.contains('\'') { + return format!("'{input}'"); + } + + let segments: Vec<&str> = input.split('"').collect(); + let mut parts: Vec = Vec::new(); + for (index, part) in segments.iter().enumerate() { + if !part.is_empty() { + parts.push(format!("\"{part}\"")); + } + if index + 1 < segments.len() { + parts.push("'\"'".to_string()); + } + } + + if parts.is_empty() { + "\"\"".to_string() + } else { + format!("concat({})", parts.join(",")) + } +} + +fn webdriver_key(key: &str) -> String { + match key.trim().to_ascii_lowercase().as_str() { + "enter" => Key::Enter.to_string(), + "return" => Key::Return.to_string(), + "tab" => Key::Tab.to_string(), + "escape" | "esc" => Key::Escape.to_string(), + "backspace" => Key::Backspace.to_string(), + "delete" => Key::Delete.to_string(), + "space" => Key::Space.to_string(), + "arrowup" | "up" => Key::Up.to_string(), + "arrowdown" | "down" => Key::Down.to_string(), + "arrowleft" | "left" => Key::Left.to_string(), + "arrowright" | "right" => Key::Right.to_string(), + "home" => Key::Home.to_string(), + "end" => Key::End.to_string(), + "pageup" => Key::PageUp.to_string(), + "pagedown" => Key::PageDown.to_string(), + other => other.to_string(), + } +} + +fn snapshot_script(interactive_only: bool, compact: bool, depth: Option) -> String { + let depth_literal = depth + .map(|level| level.to_string()) + .unwrap_or_else(|| "null".to_string()); + + format!( + r#"(() => {{ + const interactiveOnly = {interactive_only}; + const compact = {compact}; + const maxDepth = {depth_literal}; + const nodes = []; + const root = document.body || document.documentElement; + let counter = 0; + + const isVisible = (el) => {{ + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || 1) === 0) {{ + return false; + }} + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }}; + + const isInteractive = (el) => {{ + if (el.matches('a,button,input,select,textarea,summary,[role],*[tabindex]')) return true; + return typeof el.onclick === 'function'; + }}; + + const describe = (el, depth) => {{ + const interactive = isInteractive(el); + const text = (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 140); + if (interactiveOnly && !interactive) return; + if (compact && !interactive && !text) return; + + const ref = '@e' + (++counter); + el.setAttribute('data-zc-ref', ref); + nodes.push({{ + ref, + depth, + tag: el.tagName.toLowerCase(), + id: el.id || null, + role: el.getAttribute('role'), + text, + interactive, + }}); + }}; + + const walk = (el, depth) => {{ + if (!(el instanceof Element)) return; + if (maxDepth !== null && depth > maxDepth) return; + if (isVisible(el)) {{ + describe(el, depth); + }} + for (const child of el.children) {{ + walk(child, depth + 1); + if (nodes.length >= 400) return; + }} + }}; + + if (root) walk(root, 0); + + return {{ + title: document.title, + url: window.location.href, + count: nodes.length, + nodes, + }}; +}})();"# + ) +} diff --git a/src/openhuman/tools/impl/browser/security.rs b/src/openhuman/tools/impl/browser/security.rs new file mode 100644 index 000000000..f483dfee5 --- /dev/null +++ b/src/openhuman/tools/impl/browser/security.rs @@ -0,0 +1,150 @@ +use std::net::ToSocketAddrs; +use std::time::Duration; + +pub(crate) fn normalize_domains(domains: Vec) -> Vec { + domains + .into_iter() + .map(|d| d.trim().to_lowercase()) + .filter(|d| !d.is_empty()) + .collect() +} + +pub(crate) fn endpoint_reachable(endpoint: &reqwest::Url, timeout: Duration) -> bool { + let host = match endpoint.host_str() { + Some(host) if !host.is_empty() => host, + _ => return false, + }; + + let port = match endpoint.port_or_known_default() { + Some(port) => port, + None => return false, + }; + + let mut addrs = match (host, port).to_socket_addrs() { + Ok(addrs) => addrs, + Err(_) => return false, + }; + + let addr = match addrs.next() { + Some(addr) => addr, + None => return false, + }; + + std::net::TcpStream::connect_timeout(&addr, timeout).is_ok() +} + +pub(crate) fn extract_host(url_str: &str) -> anyhow::Result { + // Simple host extraction without url crate + let url = url_str.trim(); + let without_scheme = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .or_else(|| url.strip_prefix("file://")) + .unwrap_or(url); + + // Extract host — handle bracketed IPv6 addresses like [::1]:8080 + let authority = without_scheme.split('/').next().unwrap_or(without_scheme); + + let host = if authority.starts_with('[') { + // IPv6: take everything up to and including the closing ']' + authority.find(']').map_or(authority, |i| &authority[..=i]) + } else { + // IPv4 or hostname: take everything before the port separator + authority.split(':').next().unwrap_or(authority) + }; + + if host.is_empty() { + anyhow::bail!("Invalid URL: no host"); + } + + Ok(host.to_lowercase()) +} + +pub(crate) fn is_private_host(host: &str) -> bool { + // Strip brackets from IPv6 addresses like [::1] + let bare = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + + if bare == "localhost" || bare.ends_with(".localhost") { + return true; + } + + // .local TLD (mDNS) + if bare + .rsplit('.') + .next() + .is_some_and(|label| label == "local") + { + return true; + } + + // Parse as IP address to catch all representations (decimal, hex, octal, mapped) + if let Ok(ip) = bare.parse::() { + return match ip { + std::net::IpAddr::V4(v4) => is_non_global_v4(v4), + std::net::IpAddr::V6(v6) => is_non_global_v6(v6), + }; + } + + false +} + +/// Returns `true` for any IPv4 address that is not globally routable. +pub(crate) fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { + let [a, b, _, _] = v4.octets(); + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified() + || v4.is_broadcast() + || v4.is_multicast() + // Shared address space (100.64/10) + || (a == 100 && (64..=127).contains(&b)) + // Reserved (240.0.0.0/4) + || a >= 240 + // Documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) + || (a == 192 && b == 0) + || (a == 198 && b == 51) + || (a == 203 && b == 0) + // Benchmarking (198.18.0.0/15) + || (a == 198 && (18..=19).contains(&b)) +} + +/// Returns `true` for any IPv6 address that is not globally routable. +pub(crate) fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { + let segs = v6.segments(); + v6.is_loopback() + || v6.is_unspecified() + || v6.is_multicast() + // Unique-local (fc00::/7) — IPv6 equivalent of RFC 1918 + || (segs[0] & 0xfe00) == 0xfc00 + // Link-local (fe80::/10) + || (segs[0] & 0xffc0) == 0xfe80 + // IPv4-mapped addresses + || v6.to_ipv4_mapped().is_some_and(is_non_global_v4) +} + +pub(crate) fn allow_all_browser_domains() -> bool { + matches!( + std::env::var("OPENHUMAN_BROWSER_ALLOW_ALL").ok().as_deref(), + Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES") + ) +} + +pub(crate) fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool { + allowed.iter().any(|pattern| { + if pattern == "*" { + return true; + } + if pattern.starts_with("*.") { + // Wildcard subdomain match + let suffix = &pattern[1..]; // ".example.com" + host.ends_with(suffix) || host == &pattern[2..] + } else { + // Exact match or subdomain + host == pattern || host.ends_with(&format!(".{pattern}")) + } + }) +} diff --git a/src/openhuman/tools/impl/browser/types.rs b/src/openhuman/tools/impl/browser/types.rs new file mode 100644 index 000000000..1176b7f85 --- /dev/null +++ b/src/openhuman/tools/impl/browser/types.rs @@ -0,0 +1,167 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Computer-use sidecar settings. +#[derive(Clone)] +pub struct ComputerUseConfig { + pub endpoint: String, + pub api_key: Option, + pub timeout_ms: u64, + pub allow_remote_endpoint: bool, + pub window_allowlist: Vec, + pub max_coordinate_x: Option, + pub max_coordinate_y: Option, +} + +impl std::fmt::Debug for ComputerUseConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ComputerUseConfig") + .field("endpoint", &self.endpoint) + .field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]")) + .field("timeout_ms", &self.timeout_ms) + .field("allow_remote_endpoint", &self.allow_remote_endpoint) + .field("window_allowlist", &self.window_allowlist) + .field("max_coordinate_x", &self.max_coordinate_x) + .field("max_coordinate_y", &self.max_coordinate_y) + .finish() + } +} + +impl Default for ComputerUseConfig { + fn default() -> Self { + Self { + endpoint: "http://127.0.0.1:8787/v1/actions".into(), + api_key: None, + timeout_ms: 15_000, + allow_remote_endpoint: false, + window_allowlist: Vec::new(), + max_coordinate_x: None, + max_coordinate_y: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BrowserBackendKind { + AgentBrowser, + RustNative, + ComputerUse, + Auto, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResolvedBackend { + AgentBrowser, + RustNative, + ComputerUse, +} + +impl BrowserBackendKind { + pub(crate) fn parse(raw: &str) -> anyhow::Result { + let key = raw.trim().to_ascii_lowercase().replace('-', "_"); + match key.as_str() { + "agent_browser" | "agentbrowser" => Ok(Self::AgentBrowser), + "rust_native" | "native" => Ok(Self::RustNative), + "computer_use" | "computeruse" => Ok(Self::ComputerUse), + "auto" => Ok(Self::Auto), + _ => anyhow::bail!( + "Unsupported browser backend '{raw}'. Use 'agent_browser', 'rust_native', 'computer_use', or 'auto'" + ), + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::AgentBrowser => "agent_browser", + Self::RustNative => "rust_native", + Self::ComputerUse => "computer_use", + Self::Auto => "auto", + } + } +} + +/// Response from agent-browser --json commands +#[derive(Debug, Deserialize)] +pub(crate) struct AgentBrowserResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +/// Response format from computer-use sidecar. +#[derive(Debug, Deserialize)] +pub(crate) struct ComputerUseResponse { + #[serde(default)] + pub success: Option, + #[serde(default)] + pub data: Option, + #[serde(default)] + pub error: Option, +} + +/// Supported browser actions +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrowserAction { + /// Navigate to a URL + Open { url: String }, + /// Get accessibility snapshot with refs + Snapshot { + #[serde(default)] + interactive_only: bool, + #[serde(default)] + compact: bool, + #[serde(default)] + depth: Option, + }, + /// Click an element by ref or selector + Click { selector: String }, + /// Fill a form field + Fill { selector: String, value: String }, + /// Type text into focused element + Type { selector: String, text: String }, + /// Get text content of element + GetText { selector: String }, + /// Get page title + GetTitle, + /// Get current URL + GetUrl, + /// Take screenshot + Screenshot { + #[serde(default)] + path: Option, + #[serde(default)] + full_page: bool, + }, + /// Wait for element or time + Wait { + #[serde(default)] + selector: Option, + #[serde(default)] + ms: Option, + #[serde(default)] + text: Option, + }, + /// Press a key + Press { key: String }, + /// Hover over element + Hover { selector: String }, + /// Scroll page + Scroll { + direction: String, + #[serde(default)] + pixels: Option, + }, + /// Check if element is visible + IsVisible { selector: String }, + /// Close browser + Close, + /// Find element by semantic locator + Find { + by: String, // role, text, label, placeholder, testid + value: String, + action: String, // click, fill, text, hover + #[serde(default)] + fill_value: Option, + }, +}