diff --git a/.gitignore b/.gitignore index 7638906..c0e3e06 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ !Cargo.lock !config.yaml.example !src/ +!src/*/ !src/*.rs !src/**/*.rs !*.md diff --git a/src/functions/fetch.rs b/src/functions/fetch.rs new file mode 100644 index 0000000..555cdcf --- /dev/null +++ b/src/functions/fetch.rs @@ -0,0 +1,124 @@ +use serde_json::Value; + +pub fn definitions() -> Vec { + vec![serde_json::json!({ + "type": "function", + "function": { + "name": "fetch", + "description": "Fetch and summarize the text content of a URL or article link.", + "parameters": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "The URL to fetch"} + }, + "required": ["url"] + } + } + })] +} + +pub async fn run(arguments: &Value) -> Value { + let url = arguments.get("url").and_then(|v| v.as_str()).unwrap_or(""); + + if !url.starts_with("http://") && !url.starts_with("https://") { + return serde_json::json!({"error": "Only http/https URLs are supported"}); + } + + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0") + .redirect(reqwest::redirect::Policy::limited(10)) + .build() + .unwrap_or_default(); + + let resp = match client.get(url).send().await { + Ok(r) => r, + Err(e) => return serde_json::json!({"error": e.to_string()}), + }; + + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if !content_type.contains("text") && !content_type.contains("json") { + return serde_json::json!({"error": format!("Unsupported content type: {content_type}")}); + } + + let text = match resp.text().await { + Ok(t) => t, + Err(e) => return serde_json::json!({"error": e.to_string()}), + }; + + let text = strip_html(&text); + let truncated: String = text.chars().take(4000).collect(); + + serde_json::json!({"url": url, "content": truncated}) +} + +fn strip_html(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut in_tag = false; + let mut in_style = false; + let mut in_script = false; + + let lower = input.to_lowercase(); + let chars: Vec = input.chars().collect(); + let lower_chars: Vec = lower.chars().collect(); + let len = chars.len(); + let mut i = 0; + + while i < len { + if i + 7 <= len { + let slice: String = lower_chars[i..i + 7].iter().collect(); + if slice == "" { + in_style = false; + i += 8; + continue; + } + } + if i + 9 <= len { + let slice: String = lower_chars[i..i + 9].iter().collect(); + if slice == "" { + in_script = false; + i += 9; + continue; + } + } + + if chars[i] == '<' { + in_tag = true; + i += 1; + continue; + } + if chars[i] == '>' { + in_tag = false; + i += 1; + continue; + } + + if !in_tag && !in_style && !in_script { + if chars[i].is_whitespace() { + if !result.ends_with(' ') { + result.push(' '); + } + } else { + result.push(chars[i]); + } + } + + i += 1; + } + + result.trim().to_string() +} diff --git a/src/functions/mod.rs b/src/functions/mod.rs new file mode 100644 index 0000000..4a12480 --- /dev/null +++ b/src/functions/mod.rs @@ -0,0 +1,38 @@ +//pub mod discord; +pub mod fetch; +//pub mod hoyoverse; +pub mod search; +pub mod weather; +use anyhow::Result; +use serde_json::Value; + +pub struct ToolContext { + //pub http: &'a serenity::Http, + pub searxng_url: String, + //pub hoyoverse_cookies: HashMap, +} + +pub fn tool_definitions() -> Vec { + let mut defs = Vec::new(); + defs.extend(search::definitions()); + defs.extend(weather::definitions()); + defs.extend(fetch::definitions()); + //defs.extend(hoyoverse::definitions()); + defs +} + +pub async fn dispatch(name: &str, arguments: Value, ctx: &ToolContext) -> Result { + let result: Value = match name { + "search" => search::run(&arguments, ctx).await, + "get_weather" => weather::run(&arguments).await, + "fetch" => fetch::run(&arguments).await, + //"hoyoverse_player_stats" => hoyoverse::player_stats(&arguments, ctx).await, + //"hoyoverse_characters" => hoyoverse::characters(&arguments, ctx).await, + //"hoyoverse_abyss" => hoyoverse::abyss(&arguments, ctx).await, + //"hoyoverse_notes" => hoyoverse::notes(&arguments, ctx).await, + //"hoyoverse_daily_checkin" => hoyoverse::daily_checkin(&arguments, ctx).await, + _ => serde_json::json!({"error": format!("Unknown tool: {name}")}), + }; + + Ok(serde_json::to_string(&result)?) +} diff --git a/src/functions/search.rs b/src/functions/search.rs new file mode 100644 index 0000000..f49b118 --- /dev/null +++ b/src/functions/search.rs @@ -0,0 +1,71 @@ +use super::ToolContext; +use serde_json::Value; + +pub fn definitions() -> Vec { + vec![serde_json::json!({ + "type": "function", + "function": { + "name": "search", + "description": "Search the web using SearXNG for current information, news, or anything the AI doesn't know.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query"}, + "num_results": { + "type": "integer", + "description": "Number of results to return (default 5, max 10)", + "default": 5 + } + }, + "required": ["query"] + } + } + })] +} + +pub async fn run(arguments: &Value, ctx: &ToolContext) -> Value { + let query = arguments + .get("query") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let num_results = arguments + .get("num_results") + .and_then(|v| v.as_u64()) + .unwrap_or(5) + .min(10) as usize; + + let client = reqwest::Client::new(); + let resp = match client + .get(format!("{}/search", ctx.searxng_url)) + .query(&[("q", query), ("format", "json"), ("categories", "general")]) + .send() + .await + { + Ok(r) => r, + Err(e) => return serde_json::json!({"error": e.to_string()}), + }; + + let data: Value = match resp.json().await { + Ok(d) => d, + Err(e) => return serde_json::json!({"error": e.to_string()}), + }; + + let results: Vec = data + .get("results") + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .take(num_results) + .map(|r| { + serde_json::json!({ + "title": r.get("title").and_then(|v| v.as_str()), + "url": r.get("url").and_then(|v| v.as_str()), + "snippet": r.get("content").and_then(|v| v.as_str()), + }) + }) + .collect() + }) + .unwrap_or_default(); + + serde_json::json!({"query": query, "results": results}) +} diff --git a/src/functions/weather.rs b/src/functions/weather.rs new file mode 100644 index 0000000..93fda0b --- /dev/null +++ b/src/functions/weather.rs @@ -0,0 +1,141 @@ +use serde_json::Value; + +pub fn definitions() -> Vec { + vec![serde_json::json!({ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location using Open-Meteo (no API key required).", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name or location, e.g. 'Tokyo' or 'New York'"} + }, + "required": ["location"] + } + } + })] +} + +pub async fn run(arguments: &Value) -> Value { + let location = arguments + .get("location") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let client = reqwest::Client::new(); + + let geo_resp = match client + .get("https://geocoding-api.open-meteo.com/v1/search") + .query(&[ + ("name", location), + ("count", "1"), + ("language", "en"), + ("format", "json"), + ]) + .send() + .await + { + Ok(r) => r, + Err(e) => return serde_json::json!({"error": e.to_string()}), + }; + + let geo_data: Value = match geo_resp.json().await { + Ok(d) => d, + Err(e) => return serde_json::json!({"error": e.to_string()}), + }; + + let Some(result) = geo_data + .get("results") + .and_then(|r| r.as_array()) + .and_then(|a| a.first()) + else { + return serde_json::json!({"error": format!("Location '{location}' not found")}); + }; + + let lat = result + .get("latitude") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let lon = result + .get("longitude") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let name = result + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(location); + let country = result.get("country").and_then(|v| v.as_str()).unwrap_or(""); + + let weather_resp = match client + .get("https://api.open-meteo.com/v1/forecast") + .query(&[ + ("latitude", &lat.to_string()), + ("longitude", &lon.to_string()), + ( + "current", + &"temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,apparent_temperature".to_string(), + ), + ("temperature_unit", &"celsius".to_string()), + ("wind_speed_unit", &"kmh".to_string()), + ]) + .send() + .await + { + Ok(r) => r, + Err(e) => return serde_json::json!({"error": e.to_string()}), + }; + + let weather_data: Value = match weather_resp.json().await { + Ok(d) => d, + Err(e) => return serde_json::json!({"error": e.to_string()}), + }; + + let current = match weather_data.get("current") { + Some(c) => c, + None => return serde_json::json!({"error": "No current weather data"}), + }; + + let weather_code = current + .get("weather_code") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let condition = match weather_code { + 0 => "Clear sky", + 1 => "Mainly clear", + 2 => "Partly cloudy", + 3 => "Overcast", + 45 => "Foggy", + 48 => "Icy fog", + 51 => "Light drizzle", + 53 => "Drizzle", + 55 => "Heavy drizzle", + 61 => "Light rain", + 63 => "Rain", + 65 => "Heavy rain", + 71 => "Light snow", + 73 => "Snow", + 75 => "Heavy snow", + 80 => "Light showers", + 81 => "Showers", + 82 => "Heavy showers", + 95 => "Thunderstorm", + _ => "Unknown", + }; + + let location_str = if country.is_empty() { + name.to_string() + } else { + format!("{name}, {country}") + }; + + serde_json::json!({ + "location": location_str, + "temperature_c": current.get("temperature_2m"), + "feels_like_c": current.get("apparent_temperature"), + "humidity_percent": current.get("relative_humidity_2m"), + "wind_speed_kmh": current.get("wind_speed_10m"), + "condition": condition, + }) +}