refactor: query_weather bash→Rust builtin, 删除旧 skill

QWeather 天气查询从外部 bash 脚本重构为 Rust 原生 builtin 工具:
- ed25519-dalek 替代 openssl JWT 签名
- reqwest + tokio::join! 并发查询替代串行 curl
- serde_json 直接解析替代 python3 子进程
- city_id 内存缓存 (HashMap, 1h TTL)
- 支持 3/7/10/15/30d 逐日 + 24/72/168h 逐时预报
- 删除 resources/skills/query_weather/ (builtin 优先级更高)
This commit is contained in:
2026-06-02 22:42:37 +08:00
parent 031f160393
commit afc43ce692
8 changed files with 659 additions and 88 deletions
+582
View File
@@ -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<String, String> {
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::<Vec<_>>()
.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<CityLocation>,
}
#[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<WeatherNow>,
}
#[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<DailyForecast>,
}
#[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<HourlyForecast>,
}
#[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<Mutex<HashMap<String, (String, Instant)>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
const CACHE_TTL: Duration = Duration::from_secs(3600);
fn get_cached_city(query: &str) -> Option<String> {
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<Self, String> {
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<String, String> {
// 检查缓存
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<serde_json::Value, String> {
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<serde_json::Value, String> {
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<serde_json::Value> = 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<serde_json::Value, String> {
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<serde_json::Value> = 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(&params);
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(&params);
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);
}
}