use anyhow::{Context, anyhow}; use ias_common::queue::ChannelMessage; use serde::Deserialize; use serde_json::{Value, json}; #[derive(Debug, Deserialize)] struct CallHttpApiArgs { endpoint_id: String, path_params: Option, query: Option, body: Option, #[allow(dead_code)] text: Option, } #[derive(Debug, Deserialize)] struct ApiDocResponse { doc: ApiDoc, } #[derive(Debug, Deserialize)] struct ApiDoc { group: String, method: String, path: String, } pub async fn call_http_api(channel: &ChannelMessage, arguments: &str) -> String { match call_http_api_inner(channel, arguments).await { Ok(result) => result, Err(error) => format!("工具调用失败: 调用 HTTP API 失败: {error}"), } } async fn call_http_api_inner(channel: &ChannelMessage, arguments: &str) -> anyhow::Result { let args: CallHttpApiArgs = serde_json::from_str(arguments).context("解析 call_http_api 参数失败")?; let endpoint_id = validate_endpoint_id(args.endpoint_id.trim())?; let doc = fetch_api_doc(&endpoint_id).await?; let path = fill_path_params(&doc.path, args.path_params.as_ref())?; let mut url = reqwest::Url::parse(&format!("{}{}", http_api_base_url(), path)) .with_context(|| format!("构造 HTTP API 地址失败: {}", doc.path))?; append_query(&mut url, args.query.as_ref())?; let method = doc.method.trim().to_ascii_uppercase(); let mut body = args.body.unwrap_or_else(|| json!({})); if !body.is_object() { return Err(anyhow!("body 必须是对象")); } if doc.group == "context" { body = json_with_identity(channel, body); } let client = reqwest::Client::new(); let mut request = match method.as_str() { "GET" => client.get(url.clone()), "POST" => client.post(url.clone()).json(&body), "PATCH" => client.patch(url.clone()).json(&body), "DELETE" => client.delete(url.clone()).json(&body), other => return Err(anyhow!("不支持的 HTTP 方法: {other}")), }; if doc.group == "context" { request = request.header("x-ias-context-token", context_api_key()?); } let response = request .send() .await .with_context(|| format!("请求 HTTP API 失败: {method} {url}"))?; let status = response.status(); let raw_body = response.text().await.unwrap_or_default(); if !status.is_success() { return Err(anyhow!( "接口返回失败: endpoint_id={endpoint_id}, status={status}, body={raw_body}" )); } let value: Value = serde_json::from_str(&raw_body).context("解析 HTTP API 响应失败")?; let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()); Ok(format!( "HTTP API 调用成功:{endpoint_id}\n{} {}\n响应:\n{}", method, path, trim_for_result(&pretty, 8_000) )) } async fn fetch_api_doc(endpoint_id: &str) -> anyhow::Result { let url = reqwest::Url::parse(&format!( "{}/v1/api-docs/{endpoint_id}", http_api_base_url() )) .context("构造 API 文档详情地址失败")?; let response = reqwest::Client::new() .get(url.clone()) .send() .await .with_context(|| format!("请求 API 文档详情失败: {url}"))?; let status = response.status(); let body = response.text().await.unwrap_or_default(); if !status.is_success() { return Err(anyhow!( "API 文档不存在或不可用: status={status}, body={body}" )); } let response: ApiDocResponse = serde_json::from_str(&body).context("解析 API 文档详情失败")?; Ok(response.doc) } fn fill_path_params(path: &str, path_params: Option<&Value>) -> anyhow::Result { let mut path = path.to_string(); let params = path_params.and_then(Value::as_object); while let Some(start) = path.find('{') { let Some(end_offset) = path[start..].find('}') else { return Err(anyhow!("API 文档 path 占位符格式错误: {path}")); }; let end = start + end_offset; let name = &path[start + 1..end]; let value = params .and_then(|params| params.get(name)) .ok_or_else(|| anyhow!("缺少 path_params.{name}"))?; let encoded = percent_encode_path_segment(&json_scalar_to_string(value)?); path.replace_range(start..=end, &encoded); } Ok(path) } fn append_query(url: &mut reqwest::Url, query: Option<&Value>) -> anyhow::Result<()> { let Some(query) = query else { return Ok(()); }; let Some(query) = query.as_object() else { return Err(anyhow!("query 必须是对象")); }; let mut pairs = url.query_pairs_mut(); for (key, value) in query { if value.is_null() { continue; } pairs.append_pair(key, &json_scalar_to_string(value)?); } Ok(()) } fn json_with_identity(channel: &ChannelMessage, mut payload: Value) -> Value { let Value::Object(map) = &mut payload else { return payload; }; map.insert("channel".to_string(), json!(channel.channel)); map.insert("account_id".to_string(), json!(channel.account_id)); map.insert("from_user_id".to_string(), json!(channel.from_user_id)); map.insert("context".to_string(), json!(channel.context)); map.insert("group_id".to_string(), json!(channel.group_id)); payload } fn json_scalar_to_string(value: &Value) -> anyhow::Result { match value { Value::String(value) => Ok(value.trim().to_string()), Value::Number(value) => Ok(value.to_string()), Value::Bool(value) => Ok(value.to_string()), Value::Null => Err(anyhow!("参数值不能为 null")), Value::Array(_) | Value::Object(_) => Err(anyhow!("参数值只能是字符串、数字或布尔值")), } } fn validate_endpoint_id(value: &str) -> anyhow::Result { if value.is_empty() { return Err(anyhow!("endpoint_id 不能为空")); } if value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'-' || byte == b'_') { Ok(value.to_string()) } else { Err(anyhow!( "endpoint_id 只能包含字母、数字、点、下划线或连字符" )) } } fn http_api_base_url() -> String { std::env::var("IA_HTTP_API_BASE_URL") .or_else(|_| std::env::var("IA_WEB_INTERNAL_URL")) .or_else(|_| std::env::var("HOST").map(|host| format!("http://{host}"))) .unwrap_or_else(|_| "http://127.0.0.1:9003".to_string()) .trim() .trim_end_matches('/') .replace("0.0.0.0", "127.0.0.1") } fn context_api_key() -> anyhow::Result { std::env::var("IA_CONTEXT_API_KEY") .or_else(|_| std::env::var("IA_CONTEXT_TOKEN")) .or_else(|_| std::env::var("ROUTER_API_KEY")) .map(|value| value.trim().to_string()) .ok() .filter(|value| !value.is_empty()) .ok_or_else(|| anyhow!("未配置 IA_CONTEXT_API_KEY/IA_CONTEXT_TOKEN/ROUTER_API_KEY")) } fn trim_for_result(value: &str, max_chars: usize) -> String { let mut text = value.chars().take(max_chars).collect::(); if value.chars().count() > max_chars { text.push_str("..."); } text } fn percent_encode_path_segment(value: &str) -> String { let mut encoded = String::new(); for byte in value.bytes() { if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { encoded.push(byte as char); } else { encoded.push_str(&format!("%{byte:02X}")); } } encoded }