1use anyhow::{anyhow, Result};
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use std::collections::HashMap;
16
17use crate::hanzo_api::HanzoApi;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20#[serde(rename_all = "snake_case")]
21pub enum NetAction {
22 Request,
23 Fetch,
24 Head,
25 Download,
26 Open,
27 Search,
28 Crawl,
29 Help,
30}
31
32impl Default for NetAction {
33 fn default() -> Self {
34 Self::Help
35 }
36}
37
38impl std::str::FromStr for NetAction {
39 type Err = anyhow::Error;
40
41 fn from_str(s: &str) -> Result<Self> {
42 match s.to_lowercase().as_str() {
43 "request" | "get" => Ok(Self::Request),
44 "fetch" => Ok(Self::Fetch),
45 "head" => Ok(Self::Head),
46 "download" | "save" => Ok(Self::Download),
47 "open" | "browse" => Ok(Self::Open),
48 "search" => Ok(Self::Search),
49 "crawl" | "mirror" => Ok(Self::Crawl),
50 "help" | "" => Ok(Self::Help),
51 _ => Err(anyhow!("Unknown action: {}", s)),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct FetchToolArgs {
58 pub action: Option<String>,
59 pub url: Option<String>,
60 pub method: Option<String>,
61 pub headers: Option<HashMap<String, String>>,
62 pub body: Option<String>,
63 pub output: Option<String>,
64 pub timeout: Option<u64>,
65 pub query: Option<String>,
66 pub depth: Option<usize>,
67 pub limit: Option<usize>,
68}
69
70pub struct FetchToolDefinition;
71
72impl FetchToolDefinition {
73 pub fn schema() -> Value {
74 json!({
75 "name": "fetch",
76 "description": "Network operations: request, fetch, head, download, open, search, crawl",
77 "inputSchema": {
78 "type": "object",
79 "properties": {
80 "action": {
81 "type": "string",
82 "enum": ["request", "fetch", "head", "download", "open", "search", "crawl", "help"],
83 "description": "Network action"
84 },
85 "url": { "type": "string", "description": "URL" },
86 "method": { "type": "string", "description": "HTTP method", "default": "GET" },
87 "headers": { "type": "object", "description": "HTTP headers" },
88 "body": { "type": "string", "description": "Request body" },
89 "output": { "type": "string", "description": "Output file/dir for download/crawl" },
90 "timeout": { "type": "number", "description": "Timeout in ms", "default": 30000 },
91 "query": { "type": "string", "description": "Search query" },
92 "depth": { "type": "number", "description": "Crawl depth", "default": 2 },
93 "limit": { "type": "number", "description": "Max results/pages", "default": 10 }
94 },
95 "required": ["action"]
96 }
97 })
98 }
99}
100
101pub struct FetchTool;
102
103impl FetchTool {
104 pub fn new() -> Self {
105 Self
106 }
107
108 fn build_client(&self, timeout: u64) -> Result<reqwest::Client> {
109 Ok(reqwest::Client::builder()
110 .timeout(std::time::Duration::from_millis(timeout))
111 .user_agent("Mozilla/5.0 (compatible; HanzoBot/1.0)")
112 .build()?)
113 }
114
115 pub async fn execute(&self, args: FetchToolArgs) -> Result<Value> {
116 let action: NetAction = args.action.as_deref().unwrap_or("help").parse()?;
117
118 match action {
119 NetAction::Request => self.request(&args).await,
120 NetAction::Fetch => self.fetch_url(&args).await,
121 NetAction::Head => self.head(&args).await,
122 NetAction::Download => self.download(&args).await,
123 NetAction::Open => self.open(&args).await,
124 NetAction::Search => self.search(&args).await,
125 NetAction::Crawl => self.crawl(&args).await,
126 NetAction::Help => Ok(self.help()),
127 }
128 }
129
130 async fn request(&self, args: &FetchToolArgs) -> Result<Value> {
131 let url = args.url.as_deref().ok_or_else(|| anyhow!("url required"))?;
132 let method = args.method.as_deref().unwrap_or("GET");
133 let timeout = args.timeout.unwrap_or(30000);
134 let client = self.build_client(timeout)?;
135
136 let mut req = match method.to_uppercase().as_str() {
137 "GET" => client.get(url),
138 "POST" => client.post(url),
139 "PUT" => client.put(url),
140 "DELETE" => client.delete(url),
141 "PATCH" => client.patch(url),
142 "HEAD" => client.head(url),
143 _ => return Err(anyhow!("Unsupported method: {}", method)),
144 };
145
146 if let Some(headers) = &args.headers {
147 for (k, v) in headers {
148 req = req.header(k.as_str(), v.as_str());
149 }
150 }
151 if let Some(body) = &args.body {
152 req = req.body(body.clone());
153 }
154
155 let resp = req.send().await?;
156 let status = resp.status().as_u16();
157 let headers: HashMap<String, String> = resp.headers()
158 .iter()
159 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
160 .collect();
161
162 let content_type = headers.get("content-type").cloned().unwrap_or_default();
163 let body_text = resp.text().await?;
164 let body_val: Value = if content_type.contains("json") {
165 serde_json::from_str(&body_text).unwrap_or(Value::String(body_text.clone()))
166 } else {
167 Value::String(if body_text.len() > 50000 { body_text[..50000].to_string() } else { body_text })
168 };
169
170 Ok(json!({
171 "ok": true,
172 "data": { "status": status, "headers": headers, "body": body_val },
173 "error": null,
174 "meta": { "tool": "fetch", "action": "request" }
175 }))
176 }
177
178 async fn fetch_url(&self, args: &FetchToolArgs) -> Result<Value> {
179 let url = args.url.as_deref().ok_or_else(|| anyhow!("url required"))?;
180 let timeout = args.timeout.unwrap_or(30000);
181 let client = self.build_client(timeout)?;
182
183 let mut req = client.get(url);
184 if let Some(headers) = &args.headers {
185 for (k, v) in headers {
186 req = req.header(k.as_str(), v.as_str());
187 }
188 }
189
190 let resp = req.send().await?;
191 let status = resp.status().as_u16();
192 let headers: HashMap<String, String> = resp.headers()
193 .iter()
194 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
195 .collect();
196 let text = resp.text().await?;
197 let text = if text.len() > 50000 { text[..50000].to_string() } else { text };
198
199 Ok(json!({
200 "ok": true,
201 "data": { "text": text, "status": status, "headers": headers },
202 "error": null,
203 "meta": { "tool": "fetch", "action": "fetch" }
204 }))
205 }
206
207 async fn head(&self, args: &FetchToolArgs) -> Result<Value> {
208 let url = args.url.as_deref().ok_or_else(|| anyhow!("url required"))?;
209 let timeout = args.timeout.unwrap_or(30000);
210 let client = self.build_client(timeout)?;
211
212 let mut req = client.head(url);
213 if let Some(headers) = &args.headers {
214 for (k, v) in headers {
215 req = req.header(k.as_str(), v.as_str());
216 }
217 }
218
219 let resp = req.send().await?;
220 let status = resp.status().as_u16();
221 let headers: HashMap<String, String> = resp.headers()
222 .iter()
223 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
224 .collect();
225
226 Ok(json!({
227 "ok": true,
228 "data": { "status": status, "headers": headers },
229 "error": null,
230 "meta": { "tool": "fetch", "action": "head" }
231 }))
232 }
233
234 async fn download(&self, args: &FetchToolArgs) -> Result<Value> {
235 let url = args.url.as_deref().ok_or_else(|| anyhow!("url required"))?;
236 let output = args.output.as_deref().ok_or_else(|| anyhow!("output path required"))?;
237 let timeout = args.timeout.unwrap_or(60000);
238 let client = self.build_client(timeout)?;
239
240 let resp = client.get(url).send().await?;
241 if !resp.status().is_success() {
242 return Ok(json!({
243 "ok": false, "data": null,
244 "error": { "code": "HTTP_ERROR", "message": format!("{}", resp.status()) },
245 "meta": { "tool": "fetch", "action": "download" }
246 }));
247 }
248
249 let bytes = resp.bytes().await?;
250 if let Some(parent) = std::path::Path::new(output).parent() {
251 tokio::fs::create_dir_all(parent).await?;
252 }
253 tokio::fs::write(output, &bytes).await?;
254
255 Ok(json!({
256 "ok": true,
257 "data": { "url": url, "output": output, "size": bytes.len() },
258 "error": null,
259 "meta": { "tool": "fetch", "action": "download" }
260 }))
261 }
262
263 async fn open(&self, args: &FetchToolArgs) -> Result<Value> {
264 let url = args.url.as_deref().ok_or_else(|| anyhow!("url required"))?;
265
266 #[cfg(target_os = "macos")]
267 let cmd = "open";
268 #[cfg(target_os = "linux")]
269 let cmd = "xdg-open";
270 #[cfg(target_os = "windows")]
271 let cmd = "start";
272
273 tokio::process::Command::new(cmd).arg(url).output().await?;
274
275 Ok(json!({
276 "ok": true,
277 "data": { "url": url, "opened": true },
278 "error": null,
279 "meta": { "tool": "fetch", "action": "open" }
280 }))
281 }
282
283 async fn search(&self, args: &FetchToolArgs) -> Result<Value> {
284 let query = args.query.as_deref().ok_or_else(|| anyhow!("query required"))?;
285
286 let api = HanzoApi::from_env();
289 if api.has_key() {
290 if let Ok(body) = api.get("/v1/websearch/search", &[("q", query.to_string())]).await {
291 let limit = args.limit.unwrap_or(10);
292 let results: Vec<Value> = body["results"]
293 .as_array()
294 .map(|arr| {
295 arr.iter()
296 .take(limit)
297 .map(|r| json!({
298 "url": r.get("url").cloned().unwrap_or(Value::Null),
299 "title": r.get("title").cloned().unwrap_or(Value::Null),
300 "content": r.get("content").cloned().unwrap_or(Value::Null),
301 }))
302 .collect()
303 })
304 .unwrap_or_default();
305 return Ok(json!({
306 "ok": true,
307 "data": { "query": query, "results": results, "count": results.len(), "source": "cloud" },
308 "error": null,
309 "meta": { "tool": "fetch", "action": "search" }
310 }));
311 }
312 }
313
314 self.search_local(args).await
315 }
316
317 async fn search_local(&self, args: &FetchToolArgs) -> Result<Value> {
318 let query = args.query.as_deref().ok_or_else(|| anyhow!("query required"))?;
319 let timeout = args.timeout.unwrap_or(15000);
320 let client = self.build_client(timeout)?;
321
322 let encoded_query: String = query.chars().map(|c| {
324 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' {
325 c.to_string()
326 } else if c == ' ' {
327 "+".to_string()
328 } else {
329 format!("%{:02X}", c as u32)
330 }
331 }).collect();
332 let url = format!("https://html.duckduckgo.com/html/?q={}", encoded_query);
333 let resp = client.get(&url).send().await?;
334 let html = resp.text().await?;
335
336 let re = regex::Regex::new(r#"class="result__title"[\s\S]*?href="([^"]*)"[^>]*>([\s\S]*?)</a>"#).unwrap();
338 let limit = args.limit.unwrap_or(10);
339 let mut results = Vec::new();
340
341 for cap in re.captures_iter(&html) {
342 if results.len() >= limit { break; }
343 let href = cap.get(1).map(|m| m.as_str()).unwrap_or("").replace("&", "&");
344 let title = cap.get(2).map(|m| m.as_str()).unwrap_or("");
345 let tag_re = regex::Regex::new(r"<[^>]+>").unwrap();
347 let clean_title = tag_re.replace_all(title, "").trim().to_string();
348 results.push(json!({ "url": href, "title": clean_title }));
349 }
350
351 Ok(json!({
352 "ok": true,
353 "data": { "query": query, "results": results, "count": results.len(), "source": "local" },
354 "error": null,
355 "meta": { "tool": "fetch", "action": "search" }
356 }))
357 }
358
359 async fn crawl(&self, args: &FetchToolArgs) -> Result<Value> {
360 let url = args.url.as_deref().ok_or_else(|| anyhow!("url required"))?;
361 let output = args.output.as_deref().ok_or_else(|| anyhow!("output directory required"))?;
362 let max_depth = args.depth.unwrap_or(2);
363 let max_pages = args.limit.unwrap_or(100);
364 let timeout = args.timeout.unwrap_or(10000);
365 let client = self.build_client(timeout)?;
366
367 let start_host = reqwest::Url::parse(url)?.host_str().unwrap_or("").to_string();
368 let mut visited = std::collections::HashSet::new();
369 let mut pages = Vec::new();
370 let mut queue = vec![(url.to_string(), 0usize)];
371
372 tokio::fs::create_dir_all(output).await?;
373
374 while let Some((current_url, depth)) = queue.first().cloned() {
375 queue.remove(0);
376 if visited.contains(¤t_url) || depth > max_depth || pages.len() >= max_pages {
377 continue;
378 }
379 if let Ok(parsed) = reqwest::Url::parse(¤t_url) {
380 if parsed.host_str().unwrap_or("") != start_host { continue; }
381 } else { continue; }
382
383 visited.insert(current_url.clone());
384
385 let resp = match client.get(¤t_url).send().await {
386 Ok(r) => r,
387 Err(_) => continue,
388 };
389 let body = match resp.text().await {
390 Ok(b) => b,
391 Err(_) => continue,
392 };
393
394 if let Ok(parsed) = reqwest::Url::parse(¤t_url) {
396 let path = parsed.path().trim_start_matches('/');
397 let file_name = if path.is_empty() || path.ends_with('/') {
398 format!("{}index.html", path)
399 } else if !path.contains('.') {
400 format!("{}.html", path)
401 } else {
402 path.to_string()
403 };
404 let full_path = std::path::Path::new(output).join(&file_name);
405 if let Some(parent) = full_path.parent() {
406 let _ = tokio::fs::create_dir_all(parent).await;
407 }
408 let _ = tokio::fs::write(&full_path, &body).await;
409 pages.push(full_path.display().to_string());
410 }
411
412 let link_re = regex::Regex::new(r#"href=["']([^"']+)["']"#).unwrap();
414 for cap in link_re.captures_iter(&body) {
415 if let Some(href) = cap.get(1) {
416 if let Ok(abs) = reqwest::Url::parse(¤t_url).and_then(|base| base.join(href.as_str())) {
417 let abs_str = abs.to_string();
418 if !visited.contains(&abs_str) {
419 queue.push((abs_str, depth + 1));
420 }
421 }
422 }
423 }
424 }
425
426 Ok(json!({
427 "ok": true,
428 "data": { "pages": pages, "count": pages.len(), "dest": output, "depth": max_depth },
429 "error": null,
430 "meta": { "tool": "fetch", "action": "crawl" }
431 }))
432 }
433
434 fn help(&self) -> Value {
435 json!({
436 "ok": true,
437 "data": {
438 "tool": "fetch",
439 "actions": {
440 "request": "Full HTTP request (requires url, optional method/headers/body)",
441 "fetch": "Simplified GET returning text (requires url)",
442 "head": "HEAD request for headers only (requires url)",
443 "download": "Save URL to file (requires url, output)",
444 "open": "Open URL in browser (requires url)",
445 "search": "Web search (requires query)",
446 "crawl": "Recursive site mirror (requires url, output)"
447 }
448 },
449 "error": null,
450 "meta": { "tool": "fetch", "action": "help" }
451 })
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn test_net_action_parse() {
461 assert_eq!("request".parse::<NetAction>().unwrap(), NetAction::Request);
462 assert_eq!("fetch".parse::<NetAction>().unwrap(), NetAction::Fetch);
463 assert_eq!("head".parse::<NetAction>().unwrap(), NetAction::Head);
464 assert_eq!("download".parse::<NetAction>().unwrap(), NetAction::Download);
465 assert_eq!("search".parse::<NetAction>().unwrap(), NetAction::Search);
466 assert_eq!("crawl".parse::<NetAction>().unwrap(), NetAction::Crawl);
467 }
468}