This commit is contained in:
alsaiduq-lab 2026-03-12 18:47:45 -06:00
parent 14210d0027
commit a04df6600b
5 changed files with 375 additions and 0 deletions

1
.gitignore vendored
View file

@ -7,6 +7,7 @@
!Cargo.lock
!config.yaml.example
!src/
!src/*/
!src/*.rs
!src/**/*.rs
!*.md

124
src/functions/fetch.rs Normal file
View file

@ -0,0 +1,124 @@
use serde_json::Value;
pub fn definitions() -> Vec<Value> {
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<char> = input.chars().collect();
let lower_chars: Vec<char> = 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 == "<style " || slice == "<style>" {
in_style = true;
}
if slice == "<script" {
in_script = true;
}
}
if i + 8 <= len {
let slice: String = lower_chars[i..i + 8].iter().collect();
if slice == "</style>" {
in_style = false;
i += 8;
continue;
}
}
if i + 9 <= len {
let slice: String = lower_chars[i..i + 9].iter().collect();
if slice == "</script>" {
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()
}

38
src/functions/mod.rs Normal file
View file

@ -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<String, String>,
}
pub fn tool_definitions() -> Vec<Value> {
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<String> {
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)?)
}

71
src/functions/search.rs Normal file
View file

@ -0,0 +1,71 @@
use super::ToolContext;
use serde_json::Value;
pub fn definitions() -> Vec<Value> {
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<Value> = 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})
}

141
src/functions/weather.rs Normal file
View file

@ -0,0 +1,141 @@
use serde_json::Value;
pub fn definitions() -> Vec<Value> {
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,
})
}