mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 14:02:19 +00:00
Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
co-authored by
Jwalin Shah
parent
078a6b29a9
commit
d9ed70142e
File diff suppressed because it is too large
Load Diff
@@ -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<String>, 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} <code>` 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<String>) {
|
||||
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<i64> {
|
||||
value.and_then(|raw| raw.trim().parse::<i64>().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<String>) -> Vec<String> {
|
||||
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::<serde_json::Value>(&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<String> {
|
||||
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<String> {
|
||||
{
|
||||
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<Config> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Option<String>> {
|
||||
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::<i64>() {
|
||||
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::<i64>() {
|
||||
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::<i64>() {
|
||||
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<ChannelMessage>) -> 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(())
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
let spans = Self::find_bot_mention_spans(text, bot_username);
|
||||
if spans.is_empty() {
|
||||
let normalized = text.split_whitespace().collect::<Vec<_>>().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::<Vec<_>>().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<Item = &'a str>,
|
||||
{
|
||||
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 <code>`.",
|
||||
&chat_id,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_update_message(
|
||||
&self,
|
||||
update: &serde_json::Value,
|
||||
) -> Option<ChannelMessage> {
|
||||
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<TelegramReactionEvent> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<String>) {
|
||||
// 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<bool> {
|
||||
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<i64>,
|
||||
) -> 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<u8>,
|
||||
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<u8>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<i64>,
|
||||
pub(crate) recent_lookup: HashSet<i64>,
|
||||
}
|
||||
|
||||
#[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<RwLock<Vec<String>>>,
|
||||
pub(crate) pairing: Option<PairingGuard>,
|
||||
pub(crate) client: reqwest::Client,
|
||||
pub(crate) typing_handle: Mutex<Option<TelegramTypingTask>>,
|
||||
pub(crate) stream_mode: StreamMode,
|
||||
pub(crate) draft_update_interval_ms: u64,
|
||||
pub(crate) silent_streaming: bool,
|
||||
pub(crate) last_draft_edit: Mutex<std::collections::HashMap<String, std::time::Instant>>,
|
||||
pub(crate) mention_only: bool,
|
||||
pub(crate) bot_username: Mutex<Option<String>>,
|
||||
pub(crate) recent_updates: Mutex<TelegramUpdateWindow>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
+16
-1534
File diff suppressed because it is too large
Load Diff
@@ -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<String> {
|
||||
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<String> = 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<ExtractionUnit> {
|
||||
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<String, String>) -> BTreeMap<String, Vec<String>> {
|
||||
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<String, RawEntity>) -> HashMap<String, String> {
|
||||
let mut by_type = HashMap::<String, Vec<String>>::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, String>) -> 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<String> {
|
||||
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<String> {
|
||||
if text.contains("OpenHuman") {
|
||||
return Some("OPENHUMAN".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn enrich_document_metadata(
|
||||
input: &NamespaceDocumentInput,
|
||||
parsed: &ParsedIngestion,
|
||||
config: &MemoryIngestionConfig,
|
||||
) -> (NamespaceDocumentInput, Vec<String>) {
|
||||
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::<BTreeSet<_>>();
|
||||
tags.extend(parsed.tags.iter().cloned());
|
||||
let tags = tags.into_iter().collect::<Vec<_>>();
|
||||
|
||||
(
|
||||
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::<String, RawEntity>::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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>(),
|
||||
order_index: Some(relation.order_index),
|
||||
metadata: Value::Object(relation.metadata),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut tags = accumulator.tags.into_iter().collect::<Vec<_>>();
|
||||
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::<Vec<_>>(),
|
||||
"preferences": accumulator.preferences.iter().cloned().collect::<Vec<_>>(),
|
||||
"extracted_entities": entities.iter().map(|entity| {
|
||||
json!({
|
||||
"name": entity.name,
|
||||
"entity_type": entity.entity_type,
|
||||
"aliases": entity.aliases,
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
});
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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<Regex> = OnceLock::new();
|
||||
REGEX
|
||||
.get_or_init(|| Regex::new(r"^(From|To|Cc):\s*(?P<value>.+)$").expect("email header regex"))
|
||||
}
|
||||
|
||||
/// Regex for identifying named email addresses (e.g., "John Doe <john@example.com>").
|
||||
pub(super) fn named_email_regex() -> &'static Regex {
|
||||
static REGEX: OnceLock<Regex> = OnceLock::new();
|
||||
REGEX.get_or_init(|| {
|
||||
Regex::new(r"(?P<name>[^,<]+?)\s*<(?P<email>[^>]+)>").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<Regex> = OnceLock::new();
|
||||
REGEX.get_or_init(|| {
|
||||
Regex::new(
|
||||
r"^(?P<subject>[A-Za-z0-9][A-Za-z0-9 ._\-/]+?)\s+(?P<predicate>works_on|depends_on|uses|evaluates|owns|prefers)\s+(?P<object>.+)$",
|
||||
)
|
||||
.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<Regex> = OnceLock::new();
|
||||
REGEX.get_or_init(|| {
|
||||
Regex::new(r"^(?P<subject>[A-Za-z][A-Za-z ._-]+?) owns (?P<object>.+)$")
|
||||
.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<Regex> = OnceLock::new();
|
||||
REGEX.get_or_init(|| {
|
||||
Regex::new(r"^(?P<subject>[A-Za-z][A-Za-z ._-]+?) prefers (?P<object>.+)$")
|
||||
.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<Regex> = OnceLock::new();
|
||||
REGEX.get_or_init(|| {
|
||||
Regex::new(r"^(?P<subject>[A-Za-z][A-Za-z ._-]+?):\s*(?P<object>.+)$")
|
||||
.expect("action item regex")
|
||||
})
|
||||
}
|
||||
|
||||
/// Regex for identifying review assignments.
|
||||
pub(super) fn will_review_regex() -> &'static Regex {
|
||||
static REGEX: OnceLock<Regex> = OnceLock::new();
|
||||
REGEX.get_or_init(|| {
|
||||
Regex::new(r"^(?P<subject>[A-Za-z][A-Za-z ._-]+?) will review (?P<object>.+)$")
|
||||
.expect("will review regex")
|
||||
})
|
||||
}
|
||||
|
||||
/// Regex for identifying complex giving/receiving interactions.
|
||||
pub(super) fn recipient_regex() -> &'static Regex {
|
||||
static REGEX: OnceLock<Regex> = OnceLock::new();
|
||||
REGEX.get_or_init(|| {
|
||||
Regex::new(
|
||||
r"(?i)(?P<giver>[A-Z][A-Za-z]+(?: [A-Z][A-Za-z]+)?)\s+(gave|sent|handed|passed)\s+(?P<object>.+?)\s+to\s+(?P<recipient>[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<Regex> = OnceLock::new();
|
||||
REGEX.get_or_init(|| {
|
||||
Regex::new(
|
||||
r"(?i)(?P<head>[A-Za-z][A-Za-z0-9 _-]+?)\s+is\s+(?P<direction>north|south|east|west)\s+of\s+(?P<tail>[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<Regex> = 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<Regex> = 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<Regex> = 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<String, String>) -> &'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"
|
||||
}
|
||||
@@ -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<RelationRule> {
|
||||
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, String>,
|
||||
) -> 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::<Vec<_>>();
|
||||
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<String> {
|
||||
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<String, Value>,
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Sequential order index for reconstruction.
|
||||
pub order_index: Option<i64>,
|
||||
/// 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<String>,
|
||||
/// Complete list of identified entities.
|
||||
#[serde(default)]
|
||||
pub entities: Vec<ExtractedEntity>,
|
||||
/// Complete list of identified relations.
|
||||
#[serde(default)]
|
||||
pub relations: Vec<ExtractedRelation>,
|
||||
}
|
||||
|
||||
/// 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<usize>,
|
||||
/// Global sequential index for ordering within the document.
|
||||
pub(super) order_index: i64,
|
||||
/// JSON metadata for the relation.
|
||||
pub(super) metadata: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// 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<String, RawEntity>,
|
||||
/// Collected relations before final canonicalization.
|
||||
pub(super) relations: Vec<RawRelation>,
|
||||
/// Tags identified during processing.
|
||||
pub(super) tags: BTreeSet<String>,
|
||||
/// Decisions identified during processing.
|
||||
pub(super) decisions: BTreeSet<String>,
|
||||
/// User preferences identified during processing.
|
||||
pub(super) preferences: BTreeSet<String>,
|
||||
/// Inferred document kind (e.g., "profile").
|
||||
pub(super) doc_kind: Option<String>,
|
||||
/// The document's inferred primary subject.
|
||||
pub(super) primary_subject: Option<String>,
|
||||
/// Sanitized document title.
|
||||
pub(super) document_title: Option<String>,
|
||||
/// The subject of the current markdown section.
|
||||
pub(super) current_subject: Option<String>,
|
||||
/// Current sender if processing a message/thread.
|
||||
pub(super) current_sender: Option<String>,
|
||||
/// Mapping of names to their canonicalized full name.
|
||||
pub(super) known_people: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// The result of the parsing stage of ingestion.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ParsedIngestion {
|
||||
pub(super) tags: Vec<String>,
|
||||
pub(super) metadata: Value,
|
||||
pub(super) entities: Vec<ExtractedEntity>,
|
||||
pub(super) relations: Vec<ExtractedRelation>,
|
||||
pub(super) chunk_count: usize,
|
||||
pub(super) preference_count: usize,
|
||||
pub(super) decision_count: usize,
|
||||
}
|
||||
@@ -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<u8> = match serde_json::from_slice::<serde_json::Value>(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<T: Serialize>(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<Message>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Message {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiChatResponse {
|
||||
choices: Vec<Choice>,
|
||||
/// Standard OpenAI usage block.
|
||||
#[serde(default)]
|
||||
usage: Option<ApiUsage>,
|
||||
/// OpenHuman backend metadata (usage + billing summary).
|
||||
#[serde(default)]
|
||||
openhuman: Option<OpenHumanMeta>,
|
||||
}
|
||||
|
||||
#[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<PromptTokensDetails>,
|
||||
}
|
||||
|
||||
#[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<OpenHumanUsage>,
|
||||
#[serde(default)]
|
||||
billing: Option<OpenHumanBilling>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct OpenHumanUsage {
|
||||
input_tokens: Option<u64>,
|
||||
output_tokens: Option<u64>,
|
||||
#[allow(dead_code)]
|
||||
total_tokens: Option<u64>,
|
||||
cached_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct OpenHumanBilling {
|
||||
#[serde(default)]
|
||||
charged_amount_usd: f64,
|
||||
}
|
||||
|
||||
/// Remove `<think>...</think>` 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 `<think>` 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("<think>") {
|
||||
result.push_str(&rest[..start]);
|
||||
if let Some(end) = rest[start..].find("</think>") {
|
||||
rest = &rest[start + end + "</think>".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<String>,
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<ToolCall>>,
|
||||
#[serde(default)]
|
||||
function_call: Option<Function>,
|
||||
}
|
||||
|
||||
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 `<think>...</think>` 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<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 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<String>,
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
function: Option<Function>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct Function {
|
||||
name: Option<String>,
|
||||
arguments: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeChatRequest {
|
||||
model: String,
|
||||
messages: Vec<NativeMessage>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ResponsesRequest {
|
||||
model: String,
|
||||
input: Vec<ResponsesInput>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ResponsesInput {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsesResponse {
|
||||
#[serde(default)]
|
||||
output: Vec<ResponsesOutput>,
|
||||
#[serde(default)]
|
||||
output_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsesOutput {
|
||||
#[serde(default)]
|
||||
content: Vec<ResponsesContent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsesContent {
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Streaming support (SSE parser)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// Server-Sent Event stream chunk for OpenAI-compatible streaming.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StreamChunkResponse {
|
||||
choices: Vec<StreamChoice>,
|
||||
#[serde(default)]
|
||||
usage: Option<ApiUsage>,
|
||||
#[serde(default)]
|
||||
openhuman: Option<OpenHumanMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StreamChoice {
|
||||
delta: StreamDelta,
|
||||
#[allow(dead_code)]
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StreamDelta {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
/// Reasoning/thinking models may stream output via `reasoning_content`.
|
||||
#[serde(default)]
|
||||
reasoning_content: Option<String>,
|
||||
/// 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<Vec<StreamToolCallDelta>>,
|
||||
}
|
||||
|
||||
#[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<u32>,
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
#[allow(dead_code)]
|
||||
kind: Option<String>,
|
||||
#[serde(default)]
|
||||
function: Option<StreamToolCallFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StreamToolCallFunction {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Parse SSE (Server-Sent Events) stream from OpenAI-compatible providers.
|
||||
/// Handles the `data: {...}` format and `[DONE]` sentinel.
|
||||
fn parse_sse_line(line: &str) -> StreamResult<Option<String>> {
|
||||
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<StreamChunk>> {
|
||||
// Create a channel to send chunks
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<StreamResult<StreamChunk>>(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::<String>();
|
||||
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<String> {
|
||||
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<String>, Vec<ResponsesInput>) {
|
||||
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<String> {
|
||||
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::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn parse_chat_response_body(provider_name: &str, body: &str) -> anyhow::Result<ApiChatResponse> {
|
||||
serde_json::from_str::<ApiChatResponse>(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<serde_json::Value>) -> 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<ProviderToolCall> {
|
||||
if let Ok(call) = serde_json::from_value::<ProviderToolCall>(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<String>, Vec<ProviderToolCall>)> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
|
||||
let tool_calls_value = value.get("tool_calls")?.as_array()?;
|
||||
let tool_calls: Vec<ProviderToolCall> = 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<ResponsesResponse> {
|
||||
serde_json::from_str::<ResponsesResponse>(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::<Vec<_>>();
|
||||
@@ -1085,7 +405,9 @@ impl OpenAiCompatibleProvider {
|
||||
}
|
||||
|
||||
if message.role == "tool" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&message.content) {
|
||||
if let Ok(value) =
|
||||
serde_json::from_str::<serde_json::Value>(&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<String>,
|
||||
name: Option<String>,
|
||||
arguments: String,
|
||||
emitted_start: bool,
|
||||
emitted_chars: usize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OpenAiCompatibleProvider {
|
||||
fn capabilities(&self) -> crate::openhuman::providers::traits::ProviderCapabilities {
|
||||
|
||||
@@ -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<T: Serialize>(
|
||||
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<u8> = match serde_json::from_slice::<serde_json::Value>(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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 `<think>...</think>` 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 `<think>` 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("<think>") {
|
||||
result.push_str(&rest[..start]);
|
||||
if let Some(end) = rest[start..].find("</think>") {
|
||||
rest = &rest[start + end + "</think>".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<Option<String>> {
|
||||
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::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub(crate) fn parse_chat_response_body(
|
||||
provider_name: &str,
|
||||
body: &str,
|
||||
) -> anyhow::Result<ApiChatResponse> {
|
||||
serde_json::from_str::<ApiChatResponse>(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<ResponsesResponse> {
|
||||
serde_json::from_str::<ResponsesResponse>(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<serde_json::Value>) -> 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<ProviderToolCall> {
|
||||
if let Ok(call) = serde_json::from_value::<ProviderToolCall>(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<String>, Vec<ProviderToolCall>)> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
|
||||
let tool_calls_value = value.get("tool_calls")?.as_array()?;
|
||||
let tool_calls: Vec<ProviderToolCall> = 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<String> {
|
||||
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<String>, Vec<ResponsesInput>) {
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
@@ -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<StreamChunk>> {
|
||||
// Create a channel to send chunks
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<StreamResult<StreamChunk>>(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::<String>();
|
||||
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()
|
||||
}
|
||||
@@ -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<Message>,
|
||||
pub(crate) temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) tool_choice: Option<String>,
|
||||
}
|
||||
|
||||
#[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<NativeMessage>,
|
||||
pub(crate) temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) tool_choice: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct NativeMessage {
|
||||
pub(crate) role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponsesRequest {
|
||||
pub(crate) model: String,
|
||||
pub(crate) input: Vec<ResponsesInput>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) stream: Option<bool>,
|
||||
}
|
||||
|
||||
#[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<Choice>,
|
||||
/// Standard OpenAI usage block.
|
||||
#[serde(default)]
|
||||
pub(crate) usage: Option<ApiUsage>,
|
||||
/// OpenHuman backend metadata (usage + billing summary).
|
||||
#[serde(default)]
|
||||
pub(crate) openhuman: Option<OpenHumanMeta>,
|
||||
}
|
||||
|
||||
#[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<PromptTokensDetails>,
|
||||
}
|
||||
|
||||
#[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<OpenHumanUsage>,
|
||||
#[serde(default)]
|
||||
pub(crate) billing: Option<OpenHumanBilling>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub(crate) struct OpenHumanUsage {
|
||||
pub(crate) input_tokens: Option<u64>,
|
||||
pub(crate) output_tokens: Option<u64>,
|
||||
#[allow(dead_code)]
|
||||
pub(crate) total_tokens: Option<u64>,
|
||||
pub(crate) cached_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) tool_calls: Option<Vec<ToolCall>>,
|
||||
#[serde(default)]
|
||||
pub(crate) function_call: Option<Function>,
|
||||
}
|
||||
|
||||
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 `<think>...</think>` 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<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 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<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: Option<String>,
|
||||
pub(crate) function: Option<Function>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub(crate) struct Function {
|
||||
pub(crate) name: Option<String>,
|
||||
pub(crate) arguments: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ResponsesResponse {
|
||||
#[serde(default)]
|
||||
pub(crate) output: Vec<ResponsesOutput>,
|
||||
#[serde(default)]
|
||||
pub(crate) output_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ResponsesOutput {
|
||||
#[serde(default)]
|
||||
pub(crate) content: Vec<ResponsesContent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ResponsesContent {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: Option<String>,
|
||||
pub(crate) text: Option<String>,
|
||||
}
|
||||
|
||||
// ── Streaming types ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Server-Sent Event stream chunk for OpenAI-compatible streaming.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct StreamChunkResponse {
|
||||
pub(crate) choices: Vec<StreamChoice>,
|
||||
#[serde(default)]
|
||||
pub(crate) usage: Option<ApiUsage>,
|
||||
#[serde(default)]
|
||||
pub(crate) openhuman: Option<OpenHumanMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct StreamChoice {
|
||||
pub(crate) delta: StreamDelta,
|
||||
#[allow(dead_code)]
|
||||
pub(crate) finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct StreamDelta {
|
||||
#[serde(default)]
|
||||
pub(crate) content: Option<String>,
|
||||
/// Reasoning/thinking models may stream output via `reasoning_content`.
|
||||
#[serde(default)]
|
||||
pub(crate) reasoning_content: Option<String>,
|
||||
/// 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<Vec<StreamToolCallDelta>>,
|
||||
}
|
||||
|
||||
#[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<u32>,
|
||||
#[serde(default)]
|
||||
pub(crate) id: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) function: Option<StreamToolCallFunction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct StreamToolCallFunction {
|
||||
#[serde(default)]
|
||||
pub(crate) name: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub(crate) name: Option<String>,
|
||||
pub(crate) arguments: String,
|
||||
pub(crate) emitted_start: bool,
|
||||
pub(crate) emitted_chars: usize,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
+36
-1929
File diff suppressed because it is too large
Load Diff
@@ -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<String>,
|
||||
/// Optional author name (written under frontmatter `metadata.author`).
|
||||
#[serde(default)]
|
||||
pub author: Option<String>,
|
||||
/// Optional tags (written under frontmatter `metadata.tags`).
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Optional tool hints (written to frontmatter `allowed-tools`).
|
||||
#[serde(default, rename = "allowed-tools", alias = "allowed_tools")]
|
||||
pub allowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
/// Scaffold a new SKILL.md-based skill on disk.
|
||||
///
|
||||
/// Writes `<scope-root>/<slug>/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`] → `<workspace>/.openhuman/skills/`. Requires the
|
||||
/// trust marker at `<workspace>/.openhuman/trust` to be present; otherwise
|
||||
/// rejected with an error.
|
||||
/// * [`SkillScope::Legacy`] → rejected. Callers must pick one of the
|
||||
/// above; the legacy `<workspace>/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 `<scope-root>/<slug>` 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<Skill, String> {
|
||||
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<Skill, String> {
|
||||
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<String, String> {
|
||||
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}\"")
|
||||
}
|
||||
@@ -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 `<workspace>/skills/` and a placeholder `README.md` so the folder
|
||||
/// is visible to the user. New-style skills should live under
|
||||
/// `<workspace>/.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<Skill> {
|
||||
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<Skill> {
|
||||
discover_skills_inner(home_dir, workspace_dir, trusted)
|
||||
}
|
||||
|
||||
/// Whether the workspace has opted into loading project-scope skills.
|
||||
///
|
||||
/// Looks for `<workspace>/.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<Skill> {
|
||||
// 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<String, Skill> = 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 `<workspace>/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<Skill> = by_name.into_values().collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
out
|
||||
}
|
||||
|
||||
fn user_roots(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".openhuman").join("skills"),
|
||||
home.join(".agents").join("skills"),
|
||||
]
|
||||
}
|
||||
|
||||
fn project_roots(workspace: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
workspace.join(".openhuman").join("skills"),
|
||||
workspace.join(".agents").join("skills"),
|
||||
]
|
||||
}
|
||||
|
||||
fn absorb(by_name: &mut HashMap<String, Skill>, incoming: Vec<Skill>) {
|
||||
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(|| "<unknown>".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(|| "<unknown>".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<Skill> {
|
||||
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<Skill> {
|
||||
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<String, String> {
|
||||
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)
|
||||
}
|
||||
@@ -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<u64>,
|
||||
}
|
||||
|
||||
/// 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 <url>\n
|
||||
/// Installed to <path>`. Repurposed from the old npx stdout field so the
|
||||
/// UI success panel keeps the same `<details>` 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<String>,
|
||||
}
|
||||
|
||||
/// Install a skill by fetching its `SKILL.md` directly over HTTPS and writing
|
||||
/// it to `<workspace>/.openhuman/skills/<slug>/SKILL.md`.
|
||||
///
|
||||
/// Design rationale: openhuman's skill discovery scans
|
||||
/// `<workspace>/.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/<o>/<r>/blob/<b>/<p>` is rewritten to the raw
|
||||
/// `raw.githubusercontent.com/<o>/<r>/<b>/<p>` 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<InstallSkillFromUrlOutcome, String> {
|
||||
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<String> =
|
||||
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/<slug>`), which `discover_skills`
|
||||
// scans unconditionally. Project scope (`<ws>/.openhuman/skills/`) is gated on
|
||||
// a `<ws>/.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<String> = 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/<slug>/`. 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<UninstallSkillOutcome, String> {
|
||||
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/<o>/<r>/blob/<branch>/<path>` 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<String, String> {
|
||||
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<String, String> {
|
||||
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::<Ipv4Addr>() {
|
||||
return is_private_v4(&v4);
|
||||
}
|
||||
if let Ok(v6) = stripped.parse::<Ipv6Addr>() {
|
||||
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
|
||||
}
|
||||
@@ -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<String>)> {
|
||||
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<String>)> {
|
||||
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::<SkillFrontmatter>(&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<PathBuf> {
|
||||
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<PathBuf>) {
|
||||
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<String> {
|
||||
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::<LegacySkillManifest>(&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<String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (`<ws>/.openhuman/skills/...`).
|
||||
/// Requires the trust marker to be loaded.
|
||||
Project,
|
||||
/// Skill discovered under the legacy `<workspace>/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<String>,
|
||||
#[serde(default)]
|
||||
pub compatibility: Option<String>,
|
||||
/// Spec-compliant metadata map. Version, author, tags, and other
|
||||
/// non-required fields live here.
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, serde_yaml::Value>,
|
||||
/// 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<String>,
|
||||
/// 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<String, serde_yaml::Value>,
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_string(fm: &SkillFrontmatter, key: &str) -> Option<String> {
|
||||
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<String> {
|
||||
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>) -> 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<String>) -> Option<String> {
|
||||
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<String>) -> Vec<String> {
|
||||
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<String>,
|
||||
/// Tags declared in frontmatter.
|
||||
pub tags: Vec<String>,
|
||||
/// Tool hint declared in frontmatter (`allowed-tools`).
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
/// Prompt files declared in legacy `skill.json`. Unused for SKILL.md skills.
|
||||
#[serde(default)]
|
||||
pub prompts: Vec<String>,
|
||||
/// Path to the `SKILL.md` (or `skill.json`) file.
|
||||
pub location: Option<PathBuf>,
|
||||
/// 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<PathBuf>,
|
||||
/// Where the skill came from.
|
||||
#[serde(default)]
|
||||
pub scope: SkillScope,
|
||||
/// True when loaded from the legacy `skill.json` / `<ws>/skills/` layout.
|
||||
#[serde(default)]
|
||||
pub legacy: bool,
|
||||
/// Non-fatal parse warnings, surfaced in the catalog for user debugging.
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub prompts: Vec<String>,
|
||||
}
|
||||
@@ -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<BrowserAction> {
|
||||
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)
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Client>,
|
||||
}
|
||||
|
||||
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<Value> {
|
||||
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<String, Value> = Map::new();
|
||||
let mut chrome_options: Map<String, Value> = Map::new();
|
||||
let mut args: Vec<Value> = 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<fantoccini::elements::Element> {
|
||||
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<bool> {
|
||||
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<String> = 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<i64>) -> 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,
|
||||
}};
|
||||
}})();"#
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) fn normalize_domains(domains: Vec<String>) -> Vec<String> {
|
||||
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<String> {
|
||||
// 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::<std::net::IpAddr>() {
|
||||
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}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub timeout_ms: u64,
|
||||
pub allow_remote_endpoint: bool,
|
||||
pub window_allowlist: Vec<String>,
|
||||
pub max_coordinate_x: Option<i64>,
|
||||
pub max_coordinate_y: Option<i64>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<Value>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Response format from computer-use sidecar.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ComputerUseResponse {
|
||||
#[serde(default)]
|
||||
pub success: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub data: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
},
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
full_page: bool,
|
||||
},
|
||||
/// Wait for element or time
|
||||
Wait {
|
||||
#[serde(default)]
|
||||
selector: Option<String>,
|
||||
#[serde(default)]
|
||||
ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
},
|
||||
/// Press a key
|
||||
Press { key: String },
|
||||
/// Hover over element
|
||||
Hover { selector: String },
|
||||
/// Scroll page
|
||||
Scroll {
|
||||
direction: String,
|
||||
#[serde(default)]
|
||||
pixels: Option<u32>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user