diff --git a/Cargo.lock b/Cargo.lock index 19bbb0c..5c566ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -538,6 +538,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -620,6 +647,30 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -761,6 +812,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1186,6 +1243,7 @@ dependencies = [ "dialoguer", "dirs", "dotenvy", + "ed25519-dalek", "futures-util", "hex", "image", @@ -2333,6 +2391,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" diff --git a/Cargo.toml b/Cargo.toml index 15377d0..d99d1fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,3 +41,5 @@ tracing-appender = "0.2.5" dialoguer = "0.12.0" console = "0.16.3" dirs = "6.0.0" +# Ed25519 JWT 签名(天气 API) +ed25519-dalek = { version = "2", features = ["pem"] } diff --git a/resources/skills/query_weather/SKILL.md b/resources/skills/query_weather/SKILL.md deleted file mode 100644 index 5faa3e6..0000000 --- a/resources/skills/query_weather/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ -# query_weather - -查询天气信息(和风天气 API)。 - -## Risk Level -Low - -## Parameters -```json -{ - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "城市名,如 北京、上海、合肥" - }, - "days": { - "type": "integer", - "description": "预报天数,默认3", - "default": 3 - } - }, - "required": ["location"] -} -``` - -## Execute -```bash -scripts/weather.sh -``` diff --git a/resources/skills/query_weather/scripts/weather.sh b/resources/skills/query_weather/scripts/weather.sh deleted file mode 100755 index ce2ee5a..0000000 --- a/resources/skills/query_weather/scripts/weather.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -# 和风天气查询 → 输出 JSON -read -r input -LOCATION=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('location',''))" 2>/dev/null) -DAYS=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('days',3))" 2>/dev/null) - -[ -z "$LOCATION" ] && echo '{"error":"请提供 location 参数"}' && exit 0 - -API_HOST="${QWEATHER_API_HOST}" -KEY_FILE="${QWEATHER_JWT_PRIVATE_KEY_FILE:-qweather/ed25519-private.pem}" -KEY_ID="${QWEATHER_JWT_KEY_ID}" -PROJECT_ID="${QWEATHER_JWT_PROJECT_ID}" -TTL="${QWEATHER_JWT_TTL_SECONDS:-3600}" - -NOW=$(date +%s) -HEADER=$(echo -n '{"alg":"EdDSA","kid":"'"$KEY_ID"'"}' | base64 -w0 | tr '+/' '-_' | tr -d '=') -PAYLOAD=$(echo -n '{"sub":"'"$PROJECT_ID"'","iat":'$((NOW-30))',"exp":'$((NOW+TTL))'}' | base64 -w0 | tr '+/' '-_' | tr -d '=') -INPUT="${HEADER}.${PAYLOAD}" -TMP=$(mktemp) && echo -n "$INPUT" > "$TMP" -SIG=$(openssl pkeyutl -sign -inkey "$KEY_FILE" -rawin -in "$TMP" 2>/dev/null | base64 -w0 | tr '+/' '-_' | tr -d '=') -rm -f "$TMP" -JWT="${INPUT}.${SIG}" -[ -z "$JWT" ] && JWT="${QWEATHER_JWT}" -[ -z "$JWT" ] && echo '{"error":"JWT 生成失败"}' && exit 0 - -ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$LOCATION'))") -CITY=$(curl -s --compressed "https://geoapi.qweather.com/v2/city/lookup?location=${ENCODED}" -H "Authorization: Bearer $JWT") -CITY_ID=$(echo "$CITY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['location'][0]['id'] if d.get('location') else '')" 2>/dev/null) -[ -z "$CITY_ID" ] && echo '{"error":"未找到城市: '"$LOCATION"'"}' && exit 0 - -EP_DAYS=$([ "$DAYS" -le 3 ] && echo 3 || echo 7) -NOW_RESP=$(curl -s --compressed "https://${API_HOST}/v7/weather/now?location=${CITY_ID}&lang=zh&unit=m" -H "Authorization: Bearer $JWT") -DAILY_RESP=$(curl -s --compressed "https://${API_HOST}/v7/weather/${EP_DAYS}d?location=${CITY_ID}&lang=zh&unit=m" -H "Authorization: Bearer $JWT") - -export NOW_RESP DAILY_RESP LOCATION DAYS -python3 -c " -import os, json -now = json.loads(os.environ['NOW_RESP']) -daily = json.loads(os.environ['DAILY_RESP']) -days = daily.get('daily', [])[:int(os.environ['DAYS'])] -result = { - 'location': os.environ['LOCATION'], - 'now': { - 'temp': now['now']['temp'], - 'text': now['now']['text'], - }, - 'forecast': [ - {'date': d['fxDate'], 'day': d['textDay'], 'night': d.get('textNight',''), 'tempMin': d['tempMin'], 'tempMax': d['tempMax']} - for d in days - ] -} -# 过滤空字段 -result['now'] = {k: v for k, v in result['now'].items() if v} -result['forecast'] = [{k: v for k, v in f.items() if v} for f in result['forecast']] -print(json.dumps(result, ensure_ascii=False)) -" 2>/dev/null || echo '{"error":"解析天气数据失败"}' diff --git a/src/tools/builtin.rs b/src/tools/builtin.rs index 6b97945..b6982a9 100644 --- a/src/tools/builtin.rs +++ b/src/tools/builtin.rs @@ -9,6 +9,11 @@ impl BuiltinRegistry { match name { "get_current_datetime" => Some(execute_datetime()), "manage_memos" => Some(handle_memos(args_json).await), + "query_weather" => { + let params: serde_json::Value = + serde_json::from_str(args_json).unwrap_or_default(); + Some(super::weather::execute(params).await) + } _ => None, } } @@ -30,6 +35,7 @@ impl BuiltinRegistry { parameters: serde_json::json!({"type":"object","properties":{"action":{"type":"string","enum":["add","list","delete"]},"content":{"type":"string"},"id":{"type":"integer"}}}), timeout_secs: 10, }, + super::weather::spec(), ] } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 835410f..54ba6c5 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -3,3 +3,4 @@ pub mod builtin; pub mod parser; pub mod registry; pub mod skill; +pub mod weather; diff --git a/src/tools/parser.rs b/src/tools/parser.rs index 44901b1..2fa26e4 100644 --- a/src/tools/parser.rs +++ b/src/tools/parser.rs @@ -219,13 +219,12 @@ mod tests { eprintln!("加载警告: {}", e); } } - assert_eq!(registry.count(), 9, "应该有 9 个系统技能"); + assert_eq!(registry.count(), 8, "应该有 8 个系统技能(query_weather 已重构为 builtin)"); let names: Vec<&str> = registry.list_specs().iter().map(|s| s.name.as_str()).collect(); println!("已加载技能: {:?}", names); assert!(names.contains(&"manage_memos")); assert!(names.contains(&"manage_skill")); assert!(names.contains(&"get_current_datetime")); - assert!(names.contains(&"query_weather")); assert!(names.contains(&"search_web")); assert!(names.contains(&"email")); assert!(names.contains(&"execute_shell")); diff --git a/src/tools/weather.rs b/src/tools/weather.rs new file mode 100644 index 0000000..4c2a1a3 --- /dev/null +++ b/src/tools/weather.rs @@ -0,0 +1,582 @@ +// 和风天气查询工具(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)] +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 url = format!( + "https://geoapi.qweather.com/v2/city/lookup?location={}&number=1", + location + ); + let resp: CityLookupResponse = self + .http + .get(&url) + .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 url = format!( + "https://{}/v7/weather/now?location={}&lang=zh&unit=m", + self.api_host, city_id + ); + let resp: WeatherNowResponse = self + .http + .get(&url) + .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 url = format!( + "https://{}/v7/weather/{}?location={}&lang=zh&unit=m", + self.api_host, d, city_id + ); + let resp: DailyForecastResponse = self + .http + .get(&url) + .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 url = format!( + "https://{}/v7/weather/{}?location={}&lang=zh&unit=m", + self.api_host, h, city_id + ); + let resp: HourlyForecastResponse = self + .http + .get(&url) + .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()) + .or_else(|| params.get("prompt").and_then(|v| v.as_str()).and_then(|s| { + // call_capability 可能用 prompt 透传,尝试从中提取城市名 + // 简单匹配:"北京"、"上海" 等中文名或拼音 + s.split(|c: char| c == '"' || c == '\'' || c == ',' || c == ',') + .find(|w| w.chars().any(|c| c as u32 > 127)) + })) + .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) +} + +/// 执行天气查询 +pub async fn execute(params: serde_json::Value) -> SkillResult { + 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_spec() { + let s = spec(); + assert_eq!(s.name, "query_weather"); + assert_eq!(s.risk_level, RiskLevel::Low); + } +}