Files
abot/plugins/weather/main.py
2025-12-17 13:17:51 +08:00

522 lines
20 KiB
Python
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.
from loguru import logger
import aiohttp
import os
import json
import asyncio
import datetime
from typing import Dict, Any, List, Optional, Tuple
from base.plugin_common.message_plugin_interface import MessagePluginInterface
from base.plugin_common.plugin_interface import PluginStatus
from db.connection import DBConnectionManager
from utils.decorator.plugin_decorators import plugin_stats_decorator
from utils.robot_cmd.robot_command import Feature, PermissionStatus, GroupBotManager
from utils.decorator.points_decorator import plugin_points_cost
from wechat_ipad import WechatAPIClient
# ================= Redis 管理器 =================
class WeatherRedisManager:
"""处理 Redis 数据存取"""
def __init__(self, db_manager):
self.redis = db_manager
self.prefix = "bot:weather:"
async def add_subscription(self, key: str, data: dict):
"""添加订阅 (存入标准城市名和ID)"""
redis_key = f"{self.prefix}subs"
# 存入 JSON 字符串
await self.redis.hset(redis_key, key, json.dumps(data, ensure_ascii=False))
async def remove_subscription(self, key: str):
"""取消订阅"""
redis_key = f"{self.prefix}subs"
await self.redis.hdel(redis_key, key)
async def get_all_subscriptions(self) -> Dict[str, dict]:
"""获取所有订阅数据"""
redis_key = f"{self.prefix}subs"
try:
raw_data = await self.redis.hgetall(redis_key)
result = {}
if raw_data:
for k, v in raw_data.items():
k_str = k.decode('utf-8') if isinstance(k, bytes) else k
v_str = v.decode('utf-8') if isinstance(v, bytes) else v
result[k_str] = json.loads(v_str)
return result
except Exception as e:
logger.error(f"Redis 读取订阅失败: {e}")
return {}
async def get_history(self, city_id: str) -> Optional[dict]:
"""获取某城市(ID)昨天的历史数据"""
# 注意:这里改用 city_id 作为 Key确保唯一性
redis_key = f"{self.prefix}history:{city_id}"
data = await self.redis.get(redis_key)
if data:
return json.loads(data)
return None
async def save_history(self, city_id: str, data: dict):
"""保存今天的核心数据 (Key 为 city_id)"""
redis_key = f"{self.prefix}history:{city_id}"
# 有效期 48 小时
await self.redis.set(redis_key, json.dumps(data, ensure_ascii=False), ex=172800)
# ================= 插件主体类 =================
class WeatherPlugin(MessagePluginInterface):
"""天气查询插件 (v3.0 - 城市ID精准订阅版)"""
FEATURE_KEY = "WEATHER"
FEATURE_DESCRIPTION = "🌤️ 天气查询与订阅 [天气 长沙, 订阅天气 长沙]"
@property
def name(self) -> str:
return "天气查询"
@property
def version(self) -> str:
return "3.0.0"
@property
def description(self) -> str:
return "提供精准的天气订阅服务,支持城市模糊搜索自动纠正。"
@property
def author(self) -> str:
return "fg"
@property
def command_prefix(self) -> Optional[str]:
return ""
@property
def commands(self) -> List[str]:
return self._commands
@property
def feature_key(self) -> Optional[str]:
return self.FEATURE_KEY
@property
def feature_description(self) -> Optional[str]:
return self.FEATURE_DESCRIPTION
def __init__(self):
super().__init__()
self.plugin_dir = os.path.dirname(os.path.abspath(__file__))
self.feature = self.register_feature()
self.redis_manager = None
self._config = {}
self.bot_client = None
self.task = None
def initialize(self, context: Dict[str, Any]) -> bool:
"""初始化"""
self.LOG = logger
self.LOG.info(f"正在初始化 {self.name} 插件...")
# Redis 初始化
self.db_manager = DBConnectionManager.get_instance()
self.redis_manager = WeatherRedisManager(self.db_manager)
self._commands = self._config.get("Weather", {}).get("command", ["天气", "订阅天气", "取消订阅"])
self.enable = self._config.get("Weather", {}).get("enable", True)
self.api_key = self._config.get("Weather", {}).get("API_KEY", "")
self.api_domain = self._config.get("Weather", {}).get("API_DOMAIN", "https://api.qweather.com")
return True
def start(self) -> bool:
self.LOG.info(f"[{self.name}] 插件已启动")
self.status = PluginStatus.RUNNING
self.task = asyncio.create_task(self._daily_scheduler())
return True
def stop(self) -> bool:
self.LOG.info(f"[{self.name}] 插件已停止")
self.status = PluginStatus.STOPPED
if self.task:
self.task.cancel()
return True
def can_process(self, message: Dict[str, Any]) -> bool:
if not self.enable: return False
content = str(message.get("content", "")).strip()
for cmd in ["天气", "订阅天气", "取消订阅"]:
if content.startswith(cmd): return True
return content.endswith("天气")
@plugin_stats_decorator(plugin_name="天气查询")
@plugin_points_cost(10, "天气服务消耗积分", FEATURE_KEY)
async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
content = str(message.get("content", "")).strip()
sender = message.get("sender")
roomid = message.get("roomid", "")
gbm: GroupBotManager = message.get("gbm")
bot: WechatAPIClient = message.get("bot")
if not self.bot_client: self.bot_client = bot
if roomid and gbm.get_group_permission(roomid, self.feature) == PermissionStatus.DISABLED:
return False, "没有权限"
if not self.api_key:
return False, "API密钥未配置"
# --- 业务分流 ---
# 1. 订阅逻辑 (核心修改点)
if content.startswith("订阅天气"):
if not self.redis_manager: return False, "数据库未连接"
input_city = content.replace("订阅天气", "").strip()
if not input_city: return False, "请指定城市"
# Step A: 模糊查询获取 唯一ID 和 标准名
city_info = await self._lookup_city_info(input_city)
if not city_info:
return False, f"找不到城市 [{input_city}],请检查输入。"
std_city_name = city_info['name'] # 例如 "长沙"
city_id = city_info['id'] # 例如 "101250101"
# Step B: 存入 Redis (存储标准信息)
unique_id = f"{roomid}_{sender}" if roomid else f"private_{sender}"
sub_data = {
"city_id": city_id, # 核心:以 ID 为准
"city_name": std_city_name, # 存标准名用于展示
"input_name": input_city, # 用户原始输入(可选)
"room_id": roomid,
"sender_id": sender,
"created_at": str(datetime.datetime.now())
}
await self.redis_manager.add_subscription(unique_id, sub_data)
# 反馈时告诉用户标准名
msg = f"✅ 订阅成功!\n已锁定城市:{city_info['adm1']} - {std_city_name}\n每天 08:00 推送早报。"
await bot.send_text_message(roomid or sender, msg, sender)
# Step C: 顺便存历史 (用于明天对比)
# 使用 ID 获取天气,避免再次 Geo 查询
weather_data = await self._fetch_weather_by_id(city_id)
if weather_data:
await self._save_today_as_history(city_id, weather_data)
return True, "订阅成功"
# 2. 取消订阅
elif content.startswith("取消订阅"):
if not self.redis_manager: return False, "数据库不可用"
unique_id = f"{roomid}_{sender}" if roomid else f"private_{sender}"
await self.redis_manager.remove_subscription(unique_id)
await bot.send_text_message(roomid or sender, "✅ 已取消天气订阅。", sender)
return True, "取消成功"
# 3. 普通查询 (保持原逻辑,但也建议先 lookup 再查,或者复用 lookup)
else:
input_city = self._extract_city_name(content)
if not input_city: return False, "无法识别城市"
# 这里为了简单,依然使用旧的 Name -> Search -> Weather 流程
# 或者你可以复用 _lookup_city_info + _fetch_weather_by_id 组合
weather_text = await self._get_weather_text_response(input_city)
if not weather_text: return False, "查询失败"
await bot.send_text_message(roomid or sender, weather_text, sender)
return True, "发送成功"
# ================= 定时任务系统 =================
async def _daily_scheduler(self):
"""每日 08:00 调度器"""
self.LOG.info("⏰ 天气订阅定时任务已启动...")
while self.status == PluginStatus.RUNNING:
try:
now = datetime.datetime.now()
target_time = now.replace(hour=8, minute=0, second=0, microsecond=0)
if now > target_time:
target_time += datetime.timedelta(days=1)
wait_seconds = (target_time - now).total_seconds()
self.LOG.info(f"⏳ 下一次天气推送将在 {wait_seconds:.0f} 秒后")
await asyncio.sleep(wait_seconds)
if self.bot_client and self.redis_manager:
await self._execute_daily_push()
await asyncio.sleep(60)
except asyncio.CancelledError:
break
except Exception as e:
self.LOG.error(f"定时任务异常: {e}")
await asyncio.sleep(60)
async def _execute_daily_push(self):
"""执行全量推送 (基于 ID 聚合)"""
self.LOG.info("🚀 执行每日推送...")
subs = await self.redis_manager.get_all_subscriptions()
if not subs: return
# 1. 按 [city_id] 聚合 (真正的去重)
# 结构: {"101250101": {"name": "长沙", "users": [...]}, ...}
agg_map = {}
for key, sub in subs.items():
cid = sub.get('city_id')
cname = sub.get('city_name')
if not cid: continue # 兼容旧数据或错误数据
if cid not in agg_map:
agg_map[cid] = {"name": cname, "users": []}
agg_map[cid]["users"].append(sub)
# 2. 遍历 ID 获取天气
for city_id, info in agg_map.items():
try:
city_name = info["name"]
user_list = info["users"]
# 直接用 ID 查天气 (无需 Geo API极速)
api_data = await self._fetch_weather_by_id(city_id)
if not api_data: continue
# 获取 Redis 历史 (Key 也是 ID)
history_data = await self.redis_manager.get_history(city_id)
# 生成文案
push_content = self._analyze_weather_change(city_name, api_data, history_data)
if push_content:
today_str = datetime.datetime.now().strftime("%m月%d")
final_msg = f"📅 {today_str} 天气早报\n{push_content}"
for user in user_list:
room_id = user.get('room_id')
sender_id = user.get('sender_id')
target_id = room_id if room_id else sender_id
await asyncio.sleep(0.5)
try:
if self.bot_client:
await self.bot_client.send_text_message(target_id, final_msg,
sender_id if room_id else None)
except Exception as send_e:
self.LOG.error(f"推送给 {target_id} 失败: {send_e}")
# 存档
await self._save_today_as_history(city_id, api_data)
except Exception as e:
self.LOG.error(f"处理城市ID {city_id} 推送失败: {e}")
# ================= 核心分析算法 (逻辑不变) =================
def _analyze_weather_change(self, city_name: str, api_data: dict, history_data: Optional[dict]) -> str:
"""分析天气变化"""
daily_forecast = api_data.get('daily_forecast', [])
if not daily_forecast: return ""
today = daily_forecast[0]
t_max = int(today['tempMax'])
t_min = int(today['tempMin'])
text_day = today['textDay']
alerts = self._check_alerts_and_history(today, history_data)
life_tips = self._generate_life_tips(today)
astronomy = self._get_astronomy_info(today)
clothing = self._get_clothing_advice((t_max + t_min) / 2)
msg = f"📍 {city_name} | {text_day} | {t_min}°C ~ {t_max}°C\n"
if alerts: msg += "\n⚠️ **特别关注**\n" + "\n".join(alerts) + "\n"
if life_tips: msg += "\n💡 **温馨提示**\n" + "\n".join(life_tips) + "\n"
msg += f"\n👔 **穿衣**{clothing}"
if astronomy: msg += f"\n🌙 **天文**{astronomy}"
msg += "\n\n(回复 '取消订阅' 退订)"
return msg
def _check_alerts_and_history(self, today: dict, history_data: Optional[dict]) -> List[str]:
alerts = []
t_max = int(today['tempMax'])
text_day = today['textDay']
wind_scale = today.get('windScaleDay', '0')
if history_data:
last_max = int(history_data.get('tempMax', t_max))
diff = t_max - last_max
if diff <= -6:
alerts.append(f"🥶 **气温骤降**:比昨天冷了 {abs(diff)}°C")
elif diff >= 6:
alerts.append(f"🥵 **气温飙升**:比昨天热了 {diff}°C。")
if "暴雨" in text_day:
alerts.append("🌧️ **暴雨预警**:减少户外活动。")
elif "" in text_day:
alerts.append("☔️ 今天有雨,出门带伞。")
if "" in text_day: alerts.append("❄️ 雪天路滑,注意安全。")
try:
max_wind = int(wind_scale.split('-')[-1])
if max_wind >= 5: alerts.append(f"🌬️ **大风警报**{wind_scale}级大风。")
except:
pass
return alerts
def _generate_life_tips(self, data: dict) -> List[str]:
tips = []
uv = int(data.get('uvIndex', 0))
if uv >= 8:
tips.append("☀️ 紫外线极强(UV>8),防晒!")
elif uv >= 5:
tips.append("☂️ 紫外线较强,注意防晒。")
hum = int(data.get('humidity', 0))
if hum >= 90:
tips.append("💧 潮湿(>90%),注意防潮。")
elif hum <= 20:
tips.append("🌵 干燥(<20%),多喝水。")
vis = int(data.get('vis', 25))
if vis < 1:
tips.append("🌫️ **大雾**:能见度<1km慢行")
elif vis < 5:
tips.append("👀 轻雾或霾,能见度一般。")
moon = data.get('moonPhase', '')
text_night = data.get('textNight', '')
if "" in text_night and ("满月" in moon or "" in moon):
tips.append(f"🌕 天晴,宜赏月({moon})。")
return tips
def _get_astronomy_info(self, data: dict) -> str:
sunrise = data.get('sunrise', '-')
sunset = data.get('sunset', '-')
moon = data.get('moonPhase', '')
parts = []
if sunrise != '-' and sunset != '-': parts.append(f"日出{sunrise}|日落{sunset}")
if moon: parts.append(moon)
return " ".join(parts)
def _get_clothing_advice(self, temp: float) -> str:
if temp < 0: return "羽绒服+围巾 (严寒)"
if temp < 10: return "棉衣/大衣 (冷)"
if temp < 18: return "风衣/卫衣 (凉)"
if temp < 26: return "衬衫/T恤 (舒适)"
return "短袖/裙子 (热)"
# ================= 新版数据获取 (分离 ID 和 搜索) =================
async def _lookup_city_info(self, city_name: str) -> Optional[Dict]:
"""[Step 1] 通过名称查找城市 ID ( Geo API )"""
try:
headers = {"X-QW-Api-Key": f'{self.api_key}'}
params = {"location": city_name}
geo_url = f"{self.api_domain}/geo/v2/city/lookup"
conn = aiohttp.TCPConnector(ssl=False)
async with aiohttp.request('GET', geo_url, connector=conn, headers=headers, params=params) as resp:
geo_json = await resp.json()
if geo_json.get('code') != '200' or not geo_json.get('location'):
return None
# 返回最匹配的第一个结果
return geo_json["location"][0]
except Exception as e:
self.LOG.error(f"GeoAPI 失败: {e}")
return None
async def _fetch_weather_by_id(self, city_id: str) -> Optional[Dict]:
"""[Step 2] 通过 ID 直接获取天气 ( Now + 7D ) - 极速模式"""
try:
# 获取实时天气
conn = aiohttp.TCPConnector(ssl=False)
now_url = f'{self.api_domain}/v7/weather/now?key={self.api_key}&location={city_id}'
async with aiohttp.request('GET', now_url, connector=conn) as resp:
now_json = await resp.json()
# 获取预报
conn = aiohttp.TCPConnector(ssl=False)
daily_url = f'{self.api_domain}/v7/weather/7d?key={self.api_key}&location={city_id}'
async with aiohttp.request('GET', daily_url, connector=conn) as resp:
daily_json = await resp.json()
if now_json.get('code') != '200' or daily_json.get('code') != '200':
return None
return {
"now": now_json.get('now'),
"daily_forecast": daily_json.get('daily'),
"updateTime": now_json.get('updateTime')
# 注意:这里没有 Geo 信息了,因为是直接用 ID 查的
}
except Exception as e:
self.LOG.error(f"WeatherAPI(ID) 失败: {e}")
return None
async def _save_today_as_history(self, city_id: str, api_data: dict):
"""保存历史 (Key使用 ID)"""
if not self.redis_manager: return
try:
today_fc = api_data.get('daily_forecast', [])[0]
data = {
"date": datetime.datetime.now().strftime("%Y-%m-%d"),
"tempMax": today_fc['tempMax'],
"tempMin": today_fc['tempMin'],
"text": today_fc['textDay']
}
await self.redis_manager.save_history(city_id, data)
except Exception as e:
self.LOG.error(f"存档失败: {e}")
# --- 旧版兼容方法,用于普通查询 ---
async def _get_weather_text_response(self, city_name: str) -> str:
"""普通查询流程:查找 -> 获取 -> 拼接"""
# 1. 查 ID
city_info = await self._lookup_city_info(city_name)
if not city_info: return ""
cid = city_info['id']
# 2. 查天气
raw = await self._fetch_weather_by_id(cid)
if not raw: return ""
# 3. 拼接
geo_str = f"{city_info['country']}{city_info['adm1']}{city_info['adm2']}"
now = raw['now']
daily = raw['daily_forecast']
msg = (
f"{geo_str} 实时天气☁️\n"
f"{raw['updateTime'][11:16]}\n\n"
f"🌡️ {now['temp']}℃ (体感{now['feelsLike']})\n"
f"☁️ {now['text']}\n"
f"🌬️ {now['windDir']} {now['windScale']}\n"
f"💦 湿度{now['humidity']}%\n"
f"👀 能见度{now['vis']}km\n\n"
f"☁️ 未来预报:\n"
)
for day in daily[0:3]:
d = day['fxDate'][5:]
msg += f"{d} {day['textDay']}|{day['tempMin']}~{day['tempMax']}\n"
return msg
def _extract_city_name(self, content: str) -> str:
content = content.replace(" ", "")
if content.startswith("天气"):
return content[2:]
elif content.endswith("天气"):
return content[:-2]
return ""