// 和风天气查询工具(Rust 原生实现,替代 bash 脚本) // // API: // GeoAPI: GET /geo/v2/city/lookup?location=城市名 → city_id // Now: GET /v7/weather/now?location=city_id → 实时天气 // Daily: GET /v7/weather/{3,7,10,15,30}d?location=id → 每日预报 // Hourly: GET /v7/weather/{24,72,168}h?location=id → 逐时预报 // // 认证: EdDSA JWT (Ed25519) // 缓存: city_id 内存缓存 1h TTL use base64::{Engine as _, engine::general_purpose}; use chrono::Utc; use ed25519_dalek::pkcs8::DecodePrivateKey; use ed25519_dalek::{Signer, SigningKey}; use reqwest::Client; use serde::Deserialize; use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant}; use crate::tools::skill::{RiskLevel, SkillResult, SkillSpec}; // ═══════════════════════════════════════════════ // JWT 认证 // ═══════════════════════════════════════════════ fn base64url(data: &[u8]) -> String { general_purpose::URL_SAFE_NO_PAD.encode(data) } fn generate_jwt() -> Result { let key_file = std::env::var("QWEATHER_JWT_PRIVATE_KEY_FILE").unwrap_or_else(|_| "qweather/ed25519-private.pem".into()); let pem = std::fs::read_to_string(&key_file) .map_err(|e| format!("读取密钥文件 {} 失败: {}", key_file, e))?; // 手动解析 PEM: 去掉头尾,base64 解码得到 DER,再用 from_pkcs8_der let der = pem .lines() .filter(|l| !l.starts_with("-----")) .collect::>() .join(""); let der_bytes = general_purpose::STANDARD .decode(&der) .map_err(|e| format!("Base64 解码密钥失败: {e}"))?; let signing_key = SigningKey::from_pkcs8_der(&der_bytes) .map_err(|e| format!("解析 Ed25519 密钥失败: {e}"))?; let key_id = std::env::var("QWEATHER_JWT_KEY_ID").unwrap_or_default(); let project_id = std::env::var("QWEATHER_JWT_PROJECT_ID").unwrap_or_default(); let ttl: i64 = std::env::var("QWEATHER_JWT_TTL_SECONDS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(3600); let now = Utc::now().timestamp(); // Header let header = serde_json::json!({"alg": "EdDSA", "kid": key_id}); let header_b64 = base64url(header.to_string().as_bytes()); // Payload let payload = serde_json::json!({ "sub": project_id, "iat": now - 30, "exp": now + ttl, }); let payload_b64 = base64url(payload.to_string().as_bytes()); // Sign let message = format!("{}.{}", header_b64, payload_b64); let signature = signing_key.try_sign(message.as_bytes()) .map_err(|e| format!("JWT 签名失败: {e}"))?; Ok(format!("{}.{}.{}", header_b64, payload_b64, base64url(&signature.to_bytes()))) } // ═══════════════════════════════════════════════ // API 响应类型 // ═══════════════════════════════════════════════ #[derive(Deserialize)] struct CityLookupResponse { code: String, #[serde(default)] location: Vec, } #[derive(Deserialize)] #[allow(dead_code)] struct CityLocation { id: String, name: String, #[serde(default)] adm1: String, #[serde(default)] adm2: String, } #[derive(Deserialize)] struct WeatherNowResponse { code: String, #[serde(default)] now: Option, } #[derive(Deserialize)] struct WeatherNow { #[serde(default)] temp: String, #[serde(rename = "feelsLike", default)] feels_like: String, #[serde(default)] text: String, #[serde(rename = "windDir", default)] wind_dir: String, #[serde(rename = "windScale", default)] wind_scale: String, #[serde(rename = "windSpeed", default)] wind_speed: String, #[serde(default)] humidity: String, #[serde(default)] precip: String, #[serde(default)] pressure: String, #[serde(default)] vis: String, } #[derive(Deserialize)] struct DailyForecastResponse { code: String, #[serde(default)] daily: Vec, } #[derive(Deserialize)] struct DailyForecast { #[serde(rename = "fxDate", default)] fx_date: String, #[serde(rename = "tempMax", default)] temp_max: String, #[serde(rename = "tempMin", default)] temp_min: String, #[serde(rename = "textDay", default)] text_day: String, #[serde(rename = "textNight", default)] text_night: String, #[serde(rename = "windDirDay", default)] wind_dir_day: String, #[serde(rename = "windScaleDay", default)] wind_scale_day: String, #[serde(default)] humidity: String, #[serde(rename = "uvIndex", default)] uv_index: String, } #[derive(Deserialize)] struct HourlyForecastResponse { code: String, #[serde(default)] hourly: Vec, } #[derive(Deserialize)] struct HourlyForecast { #[serde(rename = "fxTime", default)] fx_time: String, #[serde(default)] temp: String, #[serde(default)] text: String, #[serde(rename = "windDir", default)] wind_dir: String, #[serde(rename = "windScale", default)] wind_scale: String, #[serde(default)] humidity: String, #[serde(default)] pop: String, #[serde(default)] precip: String, } // ═══════════════════════════════════════════════ // 城市 ID 缓存 // ═══════════════════════════════════════════════ /// 持久化 city_id 映射,减少 API 调用 use std::sync::LazyLock; static CITY_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); const CACHE_TTL: Duration = Duration::from_secs(3600); fn get_cached_city(query: &str) -> Option { let cache = CITY_CACHE.lock().unwrap(); cache.get(query).and_then(|(id, ts)| { if ts.elapsed() < CACHE_TTL { Some(id.clone()) } else { None } }) } fn cache_city(query: String, city_id: String) { let mut cache = CITY_CACHE.lock().unwrap(); cache.insert(query, (city_id, Instant::now())); } // ═══════════════════════════════════════════════ // HTTP 客户端 // ═══════════════════════════════════════════════ struct QWeatherClient { http: Client, jwt: String, api_host: String, } impl QWeatherClient { async fn new() -> Result { let jwt = generate_jwt()?; let api_host = std::env::var("QWEATHER_API_HOST") .unwrap_or_else(|_| "api.qweather.com".into()); Ok(Self { http: Client::builder() .timeout(Duration::from_secs(10)) .build() .map_err(|e| format!("创建 HTTP client 失败: {}", e))?, jwt, api_host, }) } /// 城市查询 → LocationID async fn lookup_city(&self, location: &str) -> Result { // 检查缓存 if let Some(id) = get_cached_city(location) { tracing::debug!("🏙️ 城市缓存命中: {} → {}", location, id); return Ok(id); } let resp: CityLookupResponse = self .http .get("https://geoapi.qweather.com/v2/city/lookup") .query(&[("location", location), ("number", "1")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await .map_err(|e| format!("城市查询请求失败: {}", e))? .json() .await .map_err(|e| format!("解析城市查询响应失败: {}", e))?; if resp.code != "200" { return Err(format!("城市查询 API 返回: {}", resp.code)); } let city = resp .location .first() .ok_or_else(|| format!("未找到城市: {}", location))?; let id = city.id.clone(); cache_city(location.to_string(), id.clone()); tracing::debug!("🏙️ 城市查询: {} → {} ({})", location, city.name, id); Ok(id) } /// 实时天气 async fn weather_now(&self, city_id: &str) -> Result { let resp: WeatherNowResponse = self .http .get(format!("https://{}/v7/weather/now", self.api_host)) .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await .map_err(|e| format!("实时天气请求失败: {}", e))? .json() .await .map_err(|e| format!("解析实时天气响应失败: {}", e))?; if resp.code != "200" { return Err(format!("实时天气 API 返回: {}", resp.code)); } let now = resp.now.ok_or("实时天气数据为空")?; Ok(serde_json::json!({ "temp": now.temp, "feelsLike": now.feels_like, "text": now.text, "windDir": now.wind_dir, "windScale": now.wind_scale, "windSpeed": now.wind_speed, "humidity": now.humidity, "precip": now.precip, "pressure": now.pressure, "vis": now.vis, })) } /// 每日预报 async fn daily_forecast( &self, city_id: &str, days: u32, ) -> Result { let d = match days { 0..=3 => "3d", 4..=7 => "7d", 8..=10 => "10d", 11..=15 => "15d", _ => "30d", }; let resp: DailyForecastResponse = self .http .get(format!("https://{}/v7/weather/{}", self.api_host, d)) .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await .map_err(|e| format!("每日预报请求失败: {}", e))? .json() .await .map_err(|e| format!("解析每日预报响应失败: {}", e))?; if resp.code != "200" { return Err(format!("每日预报 API 返回: {}", resp.code)); } let forecast: Vec = resp .daily .into_iter() .take(days as usize) .map(|d| { serde_json::json!({ "date": d.fx_date, "tempMax": d.temp_max, "tempMin": d.temp_min, "textDay": d.text_day, "textNight": d.text_night, "windDirDay": d.wind_dir_day, "windScaleDay": d.wind_scale_day, "humidity": d.humidity, "uvIndex": d.uv_index, }) }) .collect(); Ok(serde_json::Value::Array(forecast)) } /// 逐时预报 async fn hourly_forecast( &self, city_id: &str, hours: u32, ) -> Result { let h = match hours { 0..=24 => "24h", 25..=72 => "72h", _ => "168h", }; let resp: HourlyForecastResponse = self .http .get(format!("https://{}/v7/weather/{}", self.api_host, h)) .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await .map_err(|e| format!("逐时预报请求失败: {}", e))? .json() .await .map_err(|e| format!("解析逐时预报响应失败: {}", e))?; if resp.code != "200" { return Err(format!("逐时预报 API 返回: {}", resp.code)); } let forecast: Vec = resp .hourly .into_iter() .take(hours as usize) .map(|h| { serde_json::json!({ "time": h.fx_time, "temp": h.temp, "text": h.text, "windDir": h.wind_dir, "windScale": h.wind_scale, "humidity": h.humidity, "pop": h.pop, "precip": h.precip, }) }) .collect(); Ok(serde_json::Value::Array(forecast)) } } // ═══════════════════════════════════════════════ // 公开接口 // ═══════════════════════════════════════════════ pub fn spec() -> SkillSpec { SkillSpec { name: "query_weather".into(), description: "查询指定城市的天气信息,包括实时天气、未来几天预报、逐小时预报。参数 location 为城市名".into(), risk_level: RiskLevel::Low, parameters: serde_json::json!({ "type": "object", "properties": { "location": {"type": "string", "description": "城市名,如 北京、上海"}, "days": {"type": "integer", "description": "预报天数,默认 3,最大 30", "default": 3}, "hours": {"type": "integer", "description": "逐时预报小时数,默认 0(不查询),最大 168", "default": 0} }, "required": ["location"] }), timeout_secs: 15, } } fn extract_params(params: &serde_json::Value) -> (&str, u32, u32) { let location = params .get("location") .and_then(|v| v.as_str()) .unwrap_or("北京"); let days = params .get("days") .and_then(|v| v.as_u64()) .unwrap_or(3) .min(30) as u32; let hours = params .get("hours") .and_then(|v| v.as_u64()) .unwrap_or(0) .min(168) as u32; (location, days.max(1), hours) } #[allow(dead_code)] fn extract_days_from_prompt(prompt: &str) -> u32 { // 匹配模式: "7天" "3日" "未来7" "7d" "7D" for pat in &["天", "日", "d", "D"] { if let Some(pos) = prompt.find(pat) { // 向前找数字 let before = &prompt[..pos]; if let Some(num_str) = before .chars() .rev() .take_while(|c| c.is_ascii_digit()) .collect::() .chars() .rev() .collect::() .into() { if let Ok(n) = num_str.parse::() { return n.min(30).max(1); } } } } 3 // 默认 } #[allow(dead_code)] fn extract_city_from_prompt(prompt: &str) -> &str { // 去掉常见前缀,取第一个2-3字中文词。GeoAPI 支持模糊搜索, // 即使多取一两个字也能正确匹配到城市 let chars: Vec = prompt.chars().collect(); let byte_pos = |idx: usize| -> usize { chars[..idx].iter().map(|c| c.len_utf8()).sum() }; // 跳过前缀 let mut i = 0; for prefix in &["帮我查一下", "帮我看看", "帮我查", "帮我", "查一下", "查询", "看看"] { if prompt.starts_with(prefix) { i = prefix.chars().count(); while i < chars.len() && chars[i] as u32 <= 0x4e00 { i += 1; } break; } } // 跳过非中文 while i < chars.len() && chars[i] as u32 <= 0x4e00 { i += 1; } // 取至多4个连续中文 let start = i; while i < chars.len() && chars[i] as u32 > 0x4e00 && i - start < 4 { i += 1; } if i - start >= 2 { &prompt[byte_pos(start)..byte_pos(i)] } else { "北京" } } pub async fn execute(params: serde_json::Value) -> SkillResult { // call_capability 透传但没有 structured 参数 → 告知正确格式 if params.get("location").is_none() && params.get("name").and_then(|v| v.as_str()) == Some("query_weather") { let spec = spec(); return SkillResult::error(format!( "参数格式错误。请将 query_weather 所需的参数直接放在 call_capability 的 prompt 字段中,格式:\ prompt: {{\"location\": \"城市名\", \"days\": 3, \"hours\": 0}}。\ 工具说明:{}。参数 schema:{}", spec.description, serde_json::to_string(&spec.parameters).unwrap_or_default() )); } let (location, days, hours) = extract_params(¶ms); tracing::info!("🌤️ 查询天气: location={}, days={}, hours={}", location, days, hours); let client = match QWeatherClient::new().await { Ok(c) => c, Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)), }; // 先查城市 ID let city_id = match client.lookup_city(location).await { Ok(id) => id, Err(e) => return SkillResult::error(e), }; // 并发查询 let now_fut = client.weather_now(&city_id); let daily_fut = client.daily_forecast(&city_id, days); let hourly_fut = client.hourly_forecast(&city_id, hours); let (now_result, daily_result, hourly_result) = tokio::join!(now_fut, daily_fut, hourly_fut); let now = match now_result { Ok(n) => n, Err(e) => { tracing::warn!("实时天气查询失败: {}", e); serde_json::Value::Null } }; let daily = match daily_result { Ok(d) => d, Err(e) => { tracing::warn!("每日预报查询失败: {}", e); serde_json::Value::Null } }; let hourly = match hourly_result { Ok(h) => h, Err(e) => { tracing::warn!("逐时预报查询失败: {}", e); serde_json::Value::Null } }; let output = serde_json::json!({ "location": location, "now": now, "forecast": daily, "hourly": hourly, }); // 过滤 null 字段 let mut output = output; if let Some(obj) = output.as_object_mut() { obj.retain(|_, v| !v.is_null()); // 过滤 forecast/hourly 中每个元素内的空值 for key in &["forecast", "hourly"] { if let Some(arr) = obj.get_mut(*key).and_then(|v| v.as_array_mut()) { for item in arr.iter_mut() { if let Some(m) = item.as_object_mut() { m.retain(|_, v| !v.is_null() && v.as_str().map_or(true, |s| !s.is_empty())); } } } } } let text = serde_json::to_string(&output).unwrap_or_default(); SkillResult::ok_with_data(text, output) } #[cfg(test)] mod tests { use super::*; #[test] fn test_jwt_generation() { // 仅验证能生成非空 token(需要有效的密钥文件) let result = generate_jwt(); if result.is_ok() { let jwt = result.unwrap(); let parts: Vec<&str> = jwt.split('.').collect(); assert_eq!(parts.len(), 3, "JWT 应该有 3 部分"); assert!(!parts[0].is_empty()); assert!(!parts[1].is_empty()); assert!(!parts[2].is_empty()); } } #[test] fn test_extract_params() { let params = serde_json::json!({"location": "上海", "days": 5}); let (loc, days, hours) = extract_params(¶ms); assert_eq!(loc, "上海"); assert_eq!(days, 5); assert_eq!(hours, 0); } #[test] fn test_extract_city_from_prompt() { // 取前缀后的连续中文给 GeoAPI 模糊搜索(多取不影响结果) assert_eq!(extract_city_from_prompt("查询合肥未来7天的天气预报"), "合肥未来"); assert_eq!(extract_city_from_prompt("北京今天天气"), "北京今天"); assert_eq!(extract_city_from_prompt("上海"), "上海"); assert_eq!(extract_city_from_prompt("帮我查一下广州"), "广州"); } #[test] fn test_extract_days() { assert_eq!(extract_days_from_prompt("查询合肥未来7天的天气预报"), 7); assert_eq!(extract_days_from_prompt("3天预报"), 3); assert_eq!(extract_days_from_prompt("北京天气"), 3); } /// 端到端测试:需要有效的 QWeather 环境变量 #[tokio::test] async fn test_execute_weather_integration() { // 跳过无环境变量的情况 if std::env::var("QWEATHER_JWT_KEY_ID").is_err() { eprintln!("跳过集成测试: 未设置 QWeather 环境变量"); return; } let result = super::execute(serde_json::json!({"location": "合肥", "days": 3})).await; assert!(result.success, "查询失败: {}", result.output); let data = result.data.expect("应有 data 字段"); assert_eq!(data["location"], "合肥"); assert!(data["now"].is_object(), "应有实时天气"); assert!(data["forecast"].is_array(), "应有预报"); } }