Files
iAs/src/tools/weather.rs
T
wunianxiao 4d149982c5 refactor: 删除外置 skills 系统,全部工具转为内置
删除:
- resources/skills/ (9个外置 skill, bash/python/node 脚本)
- src/tools/registry.rs (SkillRegistry 加载/执行)
- src/tools/parser.rs (SKILL.md 解析)
- src/skills_importer.rs (skills 导入)
- cli.rs Skills 子命令
- main.rs cmd_skills / global_skills_dir

保留:
- src/tools/builtin.rs (内置工具注册 + 审批流程)
- src/tools/weather.rs (天气查询)
- src/tools/skill.rs (精简为 SkillSpec/RiskLevel/SkillResult/ExecutionContext)
- src/tools/approval.rs (审批管理器)

main.rs 简化: 移除 SkillRegistry 加载, executor 只走 BuiltinRegistry
2026-06-03 10:28:28 +08:00

659 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 和风天气查询工具(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)]
#[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<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 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<serde_json::Value, String> {
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<serde_json::Value, String> {
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<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 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<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())
.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::<String>()
.chars()
.rev()
.collect::<String>()
.into()
{
if let Ok(n) = num_str.parse::<u32>() {
return n.min(30).max(1);
}
}
}
}
3 // 默认
}
#[allow(dead_code)]
fn extract_city_from_prompt(prompt: &str) -> &str {
// 去掉常见前缀,取第一个2-3字中文词。GeoAPI 支持模糊搜索,
// 即使多取一两个字也能正确匹配到城市
let chars: Vec<char> = 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(&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_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(), "应有预报");
}
}