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:
Generated
+67
@@ -538,6 +538,33 @@ dependencies = [
|
|||||||
"typenum",
|
"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]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -620,6 +647,30 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
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]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
@@ -761,6 +812,12 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -1186,6 +1243,7 @@ dependencies = [
|
|||||||
"dialoguer",
|
"dialoguer",
|
||||||
"dirs",
|
"dirs",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"ed25519-dalek",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"image",
|
"image",
|
||||||
@@ -2333,6 +2391,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
|
|||||||
@@ -41,3 +41,5 @@ tracing-appender = "0.2.5"
|
|||||||
dialoguer = "0.12.0"
|
dialoguer = "0.12.0"
|
||||||
console = "0.16.3"
|
console = "0.16.3"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
|
# Ed25519 JWT 签名(天气 API)
|
||||||
|
ed25519-dalek = { version = "2", features = ["pem"] }
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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":"解析天气数据失败"}'
|
|
||||||
@@ -9,6 +9,11 @@ impl BuiltinRegistry {
|
|||||||
match name {
|
match name {
|
||||||
"get_current_datetime" => Some(execute_datetime()),
|
"get_current_datetime" => Some(execute_datetime()),
|
||||||
"manage_memos" => Some(handle_memos(args_json).await),
|
"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,
|
_ => 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"}}}),
|
parameters: serde_json::json!({"type":"object","properties":{"action":{"type":"string","enum":["add","list","delete"]},"content":{"type":"string"},"id":{"type":"integer"}}}),
|
||||||
timeout_secs: 10,
|
timeout_secs: 10,
|
||||||
},
|
},
|
||||||
|
super::weather::spec(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ pub mod builtin;
|
|||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
|
pub mod weather;
|
||||||
|
|||||||
+1
-2
@@ -219,13 +219,12 @@ mod tests {
|
|||||||
eprintln!("加载警告: {}", e);
|
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();
|
let names: Vec<&str> = registry.list_specs().iter().map(|s| s.name.as_str()).collect();
|
||||||
println!("已加载技能: {:?}", names);
|
println!("已加载技能: {:?}", names);
|
||||||
assert!(names.contains(&"manage_memos"));
|
assert!(names.contains(&"manage_memos"));
|
||||||
assert!(names.contains(&"manage_skill"));
|
assert!(names.contains(&"manage_skill"));
|
||||||
assert!(names.contains(&"get_current_datetime"));
|
assert!(names.contains(&"get_current_datetime"));
|
||||||
assert!(names.contains(&"query_weather"));
|
|
||||||
assert!(names.contains(&"search_web"));
|
assert!(names.contains(&"search_web"));
|
||||||
assert!(names.contains(&"email"));
|
assert!(names.contains(&"email"));
|
||||||
assert!(names.contains(&"execute_shell"));
|
assert!(names.contains(&"execute_shell"));
|
||||||
|
|||||||
@@ -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(¶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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user