Files
iAs/src/tools/builtins/weather.rs
T
wunianxiao 1d78567102 清理废弃代码,重构长期记忆系统
核心变更:
- 删除 worker.rs + ipc.rs(废弃进程架构)
- 删除 ias listen 路径(cmd_listen / listen_loop / ToolExecutor)
- 合并 ias service → ias daemon --log-file
- 修复 main.rs call_capability 不处理上下文工具的 bug

MemoryStore 重构:
- write_for 空内容防御 + 精确去重
- 缓存结构 Vec<String> → Vec<(i32, String)>(用数据库 id 标识)
- 新增 delete_for / update_for 方法 + LLM 工具定义
- load_if_needed 避免每次消息查 DB
- 缓存 TTL(1h) + 每用户 200 条上限
- read_for limit 参数(默认 30 条)

daemon.rs:
- execute_tool 统一为递归派发,消除 call_capability 重复路由

净减少约 1400 行代码,0 warning,20 测试通过
2026-06-16 11:56:36 +08:00

621 lines
20 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 原生实现)
//!
//! 通过和风天气 API 查询实时天气、每日预报和逐小时预报。
//!
//! ### API 端点
//! - 城市查询: `GET /geo/v2/city/lookup?location=城市名` → 获取 city_id
//! - 实时天气: `GET /v7/weather/now?location=city_id`
//! - 每日预报: `GET /v7/weather/{3,7,10,15,30}d?location=id`
//! - 逐时预报: `GET /v7/weather/{24,72,168}h?location=id`
//!
//! ### 认证方式
//! 使用 EdDSA (Ed25519) JWT 进行 API 认证。
//! 密钥对存放在 `qweather/` 目录下。
//!
//! ### 环境变量
//! - `QWEATHER_API_HOST` — API 地址(默认 `api.qweather.com`
//! - `QWEATHER_JWT_KEY_ID` — JWT Key ID
//! - `QWEATHER_JWT_PROJECT_ID` — 项目 ID
//! - `QWEATHER_JWT_PRIVATE_KEY_FILE` — Ed25519 私钥路径
//!
//! ### 缓存策略
//! city_id 在内存中缓存 1 小时(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::types::{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(|_| {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".ias")
.join("qweather")
.join("ed25519-private.pem")
.to_string_lossy()
.to_string()
});
let pem = std::fs::read_to_string(&key_file)
.map_err(|e| format!("读取密钥文件 {} 失败: {}", key_file, e))?;
let signing_key = SigningKey::from_pkcs8_pem(&pem)
.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))
}
}
// ═══════════════════════════════════════════════
// 公开接口 — 3 个独立工具
// ═══════════════════════════════════════════════
/// 1. weather_now — 实时天气
pub fn now_spec() -> SkillSpec {
SkillSpec {
name: "weather_now".into(),
description: "查询指定城市的实时天气(温度、体感温度、天气状况、风向风力、湿度、降水量、气压、能见度)。参数 location 为城市名".into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"location": {"type": "string", "description": "城市名,如 北京、上海"}
},
"required": ["location"]
}),
timeout_secs: 10,
}
}
pub async fn now_execute(params: serde_json::Value) -> SkillResult {
let location = params
.get("location")
.and_then(|v| v.as_str())
.unwrap_or("");
if location.is_empty() {
return SkillResult::error("请提供 location(城市名)");
}
let client = match QWeatherClient::new().await {
Ok(c) => c,
Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)),
};
let city_id = match client.lookup_city(location).await {
Ok(id) => id,
Err(e) => return SkillResult::error(e),
};
match client.weather_now(&city_id).await {
Ok(data) => {
let output = serde_json::json!({"location": location, "now": data});
SkillResult::ok_with_data(serde_json::to_string(&output).unwrap_or_default(), output)
}
Err(e) => SkillResult::error(e),
}
}
/// 2. weather_forecast — 每日预报
pub fn forecast_spec() -> SkillSpec {
SkillSpec {
name: "weather_forecast".into(),
description: "查询指定城市未来几天的天气预报(最高/最低温度、白天/夜间天气状况、风向风力、湿度、紫外线指数)。参数 location 为城市名,days 为预报天数(默认3,最大30)".into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"location": {"type": "string", "description": "城市名,如 北京、上海"},
"days": {"type": "integer", "description": "预报天数,默认3,最大30"}
},
"required": ["location"]
}),
timeout_secs: 10,
}
}
pub async fn forecast_execute(params: serde_json::Value) -> SkillResult {
let location = params
.get("location")
.and_then(|v| v.as_str())
.unwrap_or("");
if location.is_empty() {
return SkillResult::error("请提供 location(城市名)");
}
let days = params
.get("days")
.and_then(|v| v.as_u64())
.unwrap_or(3)
.min(30)
.max(1) as u32;
let client = match QWeatherClient::new().await {
Ok(c) => c,
Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)),
};
let city_id = match client.lookup_city(location).await {
Ok(id) => id,
Err(e) => return SkillResult::error(e),
};
match client.daily_forecast(&city_id, days).await {
Ok(data) => {
let output = serde_json::json!({"location": location, "forecast": data});
SkillResult::ok_with_data(serde_json::to_string(&output).unwrap_or_default(), output)
}
Err(e) => SkillResult::error(e),
}
}
/// 3. weather_hourly — 逐时预报
pub fn hourly_spec() -> SkillSpec {
SkillSpec {
name: "weather_hourly".into(),
description: "查询指定城市未来逐小时的天气预报(温度、天气状况、风向风力、湿度、降水概率、降水量)。参数 location 为城市名,hours 为预报小时数(默认24,最大168)".into(),
risk_level: RiskLevel::Low,
parameters: serde_json::json!({
"type": "object",
"properties": {
"location": {"type": "string", "description": "城市名,如 北京、上海"},
"hours": {"type": "integer", "description": "预报小时数,默认24,最大168"}
},
"required": ["location"]
}),
timeout_secs: 10,
}
}
pub async fn hourly_execute(params: serde_json::Value) -> SkillResult {
let location = params
.get("location")
.and_then(|v| v.as_str())
.unwrap_or("");
if location.is_empty() {
return SkillResult::error("请提供 location(城市名)");
}
let hours = params
.get("hours")
.and_then(|v| v.as_u64())
.unwrap_or(24)
.min(168)
.max(1) as u32;
let client = match QWeatherClient::new().await {
Ok(c) => c,
Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)),
};
let city_id = match client.lookup_city(location).await {
Ok(id) => id,
Err(e) => return SkillResult::error(e),
};
match client.hourly_forecast(&city_id, hours).await {
Ok(data) => {
let output = serde_json::json!({"location": location, "hourly": data});
SkillResult::ok_with_data(serde_json::to_string(&output).unwrap_or_default(), output)
}
Err(e) => SkillResult::error(e),
}
}
#[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());
}
}
/// 端到端测试:需要有效的 QWeather 环境变量
#[tokio::test]
async fn test_now_execute_integration() {
if std::env::var("QWEATHER_JWT_KEY_ID").is_err() {
eprintln!("跳过集成测试: 未设置 QWeather 环境变量");
return;
}
let result = super::now_execute(serde_json::json!({"location": "合肥"})).await;
assert!(result.success, "查询失败: {}", result.output);
let data = result.data.expect("应有 data 字段");
assert_eq!(data["location"], "合肥");
assert!(data["now"].is_object(), "应有实时天气");
}
#[tokio::test]
async fn test_forecast_execute_integration() {
if std::env::var("QWEATHER_JWT_KEY_ID").is_err() {
eprintln!("跳过集成测试: 未设置 QWeather 环境变量");
return;
}
let result =
super::forecast_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["forecast"].is_array(), "应有预报");
}
}