新增中国城市天气日报:AI 生成每日天气摘要并推送至 Telegram 论坛群 General 话题
- 新建 src/utils/daily_weather_report.py,覆盖 7 个中国城市(北京、上海、广州、成都、重庆、武汉、青岛) - 复用 Open-Meteo + METAR 数据源,MiMo AI 生成自然语言日报 - 注册为 runtime_coordinator 独立后台循环,默认每日 8:00 Asia/Shanghai 发送 - 环境变量:DAILY_WEATHER_REPORT_ENABLED / _HOUR / _MINUTE / _TIMEZONE Constraint: message_thread_id=0 发送到论坛群 General 子话题 Tested: ruff check pass, Python 语法验证通过
This commit is contained in:
@@ -89,6 +89,7 @@ class StartupCoordinator:
|
|||||||
self._start_weekly_reward_loop(),
|
self._start_weekly_reward_loop(),
|
||||||
self._start_payment_event_loop(),
|
self._start_payment_event_loop(),
|
||||||
self._start_payment_confirm_loop(),
|
self._start_payment_confirm_loop(),
|
||||||
|
self._start_daily_weather_report_loop(),
|
||||||
]
|
]
|
||||||
self._runtime_status = RuntimeStatus(
|
self._runtime_status = RuntimeStatus(
|
||||||
started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
|
started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||||
@@ -157,7 +158,19 @@ class StartupCoordinator:
|
|||||||
details = {
|
details = {
|
||||||
"mode": "airport-periodic",
|
"mode": "airport-periodic",
|
||||||
"interval_sec": interval,
|
"interval_sec": interval,
|
||||||
"cities": ["seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan", "taipei"],
|
"cities": [
|
||||||
|
"seoul",
|
||||||
|
"busan",
|
||||||
|
"tokyo",
|
||||||
|
"ankara",
|
||||||
|
"helsinki",
|
||||||
|
"amsterdam",
|
||||||
|
"istanbul",
|
||||||
|
"paris",
|
||||||
|
"hong kong",
|
||||||
|
"lau fau shan",
|
||||||
|
"taipei",
|
||||||
|
],
|
||||||
"chat_targets": len(chat_ids),
|
"chat_targets": len(chat_ids),
|
||||||
"window": "DEB proximity ≤3°C",
|
"window": "DEB proximity ≤3°C",
|
||||||
}
|
}
|
||||||
@@ -168,7 +181,9 @@ class StartupCoordinator:
|
|||||||
configured_enabled=enabled,
|
configured_enabled=enabled,
|
||||||
details=details,
|
details=details,
|
||||||
validation_error=validation_error,
|
validation_error=validation_error,
|
||||||
starter=lambda: import_module("src.utils.telegram_push").start_high_freq_airport_push_loop(
|
starter=lambda: import_module(
|
||||||
|
"src.utils.telegram_push"
|
||||||
|
).start_high_freq_airport_push_loop(
|
||||||
self.bot,
|
self.bot,
|
||||||
self.config,
|
self.config,
|
||||||
),
|
),
|
||||||
@@ -222,7 +237,9 @@ class StartupCoordinator:
|
|||||||
settle_weekday = min(
|
settle_weekday = min(
|
||||||
7, max(1, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY", 1))
|
7, max(1, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY", 1))
|
||||||
)
|
)
|
||||||
settle_hour = min(23, max(0, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR", 0)))
|
settle_hour = min(
|
||||||
|
23, max(0, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR", 0))
|
||||||
|
)
|
||||||
settle_minute = min(
|
settle_minute = min(
|
||||||
59, max(0, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE", 5))
|
59, max(0, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE", 5))
|
||||||
)
|
)
|
||||||
@@ -249,9 +266,9 @@ class StartupCoordinator:
|
|||||||
configured_enabled=enabled,
|
configured_enabled=enabled,
|
||||||
details=details,
|
details=details,
|
||||||
validation_error=validation_error,
|
validation_error=validation_error,
|
||||||
starter=lambda: import_module("src.bot.weekly_reward_loop").start_weekly_reward_loop(
|
starter=lambda: import_module(
|
||||||
self.bot
|
"src.bot.weekly_reward_loop"
|
||||||
),
|
).start_weekly_reward_loop(self.bot),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _start_payment_confirm_loop(self) -> LoopStatus:
|
def _start_payment_confirm_loop(self) -> LoopStatus:
|
||||||
@@ -318,6 +335,41 @@ class StartupCoordinator:
|
|||||||
).start_payment_event_loop(),
|
).start_payment_event_loop(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _start_daily_weather_report_loop(self) -> LoopStatus:
|
||||||
|
enabled = _env_bool("DAILY_WEATHER_REPORT_ENABLED", True)
|
||||||
|
chat_ids = get_telegram_chat_ids_from_env()
|
||||||
|
report_hour = _env_int("DAILY_WEATHER_REPORT_HOUR", 8)
|
||||||
|
report_minute = _env_int("DAILY_WEATHER_REPORT_MINUTE", 0)
|
||||||
|
tz_name = str(
|
||||||
|
os.getenv("DAILY_WEATHER_REPORT_TIMEZONE") or "Asia/Shanghai"
|
||||||
|
).strip()
|
||||||
|
details = {
|
||||||
|
"schedule": f"{report_hour:02d}:{report_minute:02d}",
|
||||||
|
"timezone": tz_name,
|
||||||
|
"cities": [
|
||||||
|
"beijing",
|
||||||
|
"shanghai",
|
||||||
|
"guangzhou",
|
||||||
|
"chengdu",
|
||||||
|
"chongqing",
|
||||||
|
"wuhan",
|
||||||
|
"qingdao",
|
||||||
|
],
|
||||||
|
"target": "forum_general_topic",
|
||||||
|
"chat_targets": len(chat_ids),
|
||||||
|
}
|
||||||
|
validation_error = None
|
||||||
|
return self._start_with_validation(
|
||||||
|
key="daily_weather_report",
|
||||||
|
label="中国城市天气日报",
|
||||||
|
configured_enabled=enabled,
|
||||||
|
details=details,
|
||||||
|
validation_error=validation_error,
|
||||||
|
starter=lambda: import_module(
|
||||||
|
"src.utils.daily_weather_report"
|
||||||
|
).start_daily_weather_report_loop(self.bot, self.config),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_runtime_status_html(status: RuntimeStatus) -> str:
|
def render_runtime_status_html(status: RuntimeStatus) -> str:
|
||||||
lines = [
|
lines = [
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Daily weather report for Chinese cities — AI-generated narrative pushed to Telegram."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
except Exception:
|
||||||
|
from datetime import timezone as _utc_tz
|
||||||
|
from datetime import timedelta as _td
|
||||||
|
|
||||||
|
ZoneInfo = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
from src.data_collection.city_registry import CITY_REGISTRY
|
||||||
|
from src.data_collection.weather_sources import WeatherDataCollector
|
||||||
|
|
||||||
|
TARGET_CITIES: List[str] = [
|
||||||
|
"beijing",
|
||||||
|
"shanghai",
|
||||||
|
"guangzhou",
|
||||||
|
"chengdu",
|
||||||
|
"chongqing",
|
||||||
|
"wuhan",
|
||||||
|
"qingdao",
|
||||||
|
]
|
||||||
|
|
||||||
|
FORUM_CHAT_ID = "-1003965137823"
|
||||||
|
|
||||||
|
CITY_NAME_ZH: Dict[str, str] = {
|
||||||
|
"beijing": "北京",
|
||||||
|
"shanghai": "上海",
|
||||||
|
"guangzhou": "广州",
|
||||||
|
"chengdu": "成都",
|
||||||
|
"chongqing": "重庆",
|
||||||
|
"wuhan": "武汉",
|
||||||
|
"qingdao": "青岛",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, default: int, min_val: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return max(min_val, int(os.getenv(name, str(default))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_city_data(
|
||||||
|
collector: WeatherDataCollector, city_key: str
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
info = CITY_REGISTRY.get(city_key)
|
||||||
|
if not info:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = collector.fetch_all_sources(
|
||||||
|
city_key,
|
||||||
|
lat=info["lat"],
|
||||||
|
lon=info["lon"],
|
||||||
|
include_taf=False,
|
||||||
|
include_ensemble=False,
|
||||||
|
include_multi_model=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"daily_weather_report: fetch failed for {city_key}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(results, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
om = results.get("open-meteo", {}) if isinstance(results, dict) else {}
|
||||||
|
current = om.get("current_weather", {}) if isinstance(om, dict) else {}
|
||||||
|
daily = om.get("daily", {}) if isinstance(om, dict) else {}
|
||||||
|
metar = results.get("metar", {}) if isinstance(results, dict) else {}
|
||||||
|
|
||||||
|
daily_highs = daily.get("temperature_2m_max", []) or []
|
||||||
|
today_high = daily_highs[0] if daily_highs else None
|
||||||
|
daily_times = daily.get("time", []) or []
|
||||||
|
today_date = daily_times[0] if daily_times else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"city": city_key,
|
||||||
|
"name": CITY_NAME_ZH.get(city_key, city_key),
|
||||||
|
"temp": current.get("temperature"),
|
||||||
|
"wind_speed": current.get("windspeed"),
|
||||||
|
"wind_dir": current.get("winddirection"),
|
||||||
|
"weather_code": current.get("weathercode"),
|
||||||
|
"forecast_high": today_high,
|
||||||
|
"forecast_date": today_date,
|
||||||
|
"metar_raw": (metar.get("raw_metar") or metar.get("raw") or "")
|
||||||
|
if isinstance(metar, dict)
|
||||||
|
else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ai_prompt(cities_data: List[Dict[str, Any]]) -> str:
|
||||||
|
data_json = json.dumps(cities_data, ensure_ascii=False, indent=2, default=str)
|
||||||
|
return (
|
||||||
|
"你是天气预报播报员。以下是今天中国主要城市的实时天气数据(JSON格式)。\n\n"
|
||||||
|
f"{data_json}\n\n"
|
||||||
|
"请用自然、亲切的中文写一段天气日报,要求:\n"
|
||||||
|
"1. 每个城市1-2句,描述今天天气状况(晴/多云/雨等)和温度体感\n"
|
||||||
|
"2. 温度用摄氏度(℃),风速用 km/h\n"
|
||||||
|
"3. 语气轻松自然,像朋友聊天,不要机械罗列数据\n"
|
||||||
|
"4. 可以提一句户外活动建议或穿衣提示\n"
|
||||||
|
"5. 格式:<b>城市名</b> 加粗,用 HTML 标签\n"
|
||||||
|
"6. 开头加一句日期和问候,例如「☀️ 早上好!今天是 5 月 23 日」\n"
|
||||||
|
"7. 总体控制在 500 字以内\n"
|
||||||
|
"8. metar_raw 是机场观测报文,可辅助判断天气现象\n"
|
||||||
|
"weather_code 参考:0=晴, 1-3=多云, 45-48=雾, 51-67=雨, 71-86=雪, 95-99=雷暴"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_ai(prompt: str) -> Optional[str]:
|
||||||
|
api_key = os.getenv("POLYWEATHER_SCAN_AI_API_KEY", "")
|
||||||
|
base_url = os.getenv(
|
||||||
|
"POLYWEATHER_SCAN_AI_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1"
|
||||||
|
)
|
||||||
|
model = os.getenv(
|
||||||
|
"DAILY_REPORT_AI_MODEL",
|
||||||
|
os.getenv("POLYWEATHER_SCAN_AI_MODEL", "mimo-v2.5-pro"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("daily_weather_report: AI API key not configured")
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"max_tokens": 1200,
|
||||||
|
"temperature": 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout = httpx.Timeout(timeout=30.0, connect=8.0, read=30.0)
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
resp = client.post(
|
||||||
|
f"{base_url}/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
return str(content).strip() if content else None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"daily_weather_report: AI call failed: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _runner(bot: Any, config: Dict[str, Any]) -> None:
|
||||||
|
enabled = _env_bool("DAILY_WEATHER_REPORT_ENABLED", True)
|
||||||
|
if not enabled:
|
||||||
|
logger.info("daily_weather_report: disabled by env")
|
||||||
|
return
|
||||||
|
|
||||||
|
tz_name = str(os.getenv("DAILY_WEATHER_REPORT_TIMEZONE") or "Asia/Shanghai").strip()
|
||||||
|
report_hour = _env_int("DAILY_WEATHER_REPORT_HOUR", 8)
|
||||||
|
report_minute = _env_int("DAILY_WEATHER_REPORT_MINUTE", 0)
|
||||||
|
|
||||||
|
if ZoneInfo is None:
|
||||||
|
local_tz = _utc_tz(_td(hours=8))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
local_tz = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
collector = WeatherDataCollector(config)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"daily_weather_report: started tz={} time={:02d}:{:02d} cities={}",
|
||||||
|
tz_name,
|
||||||
|
report_hour,
|
||||||
|
report_minute,
|
||||||
|
len(TARGET_CITIES),
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_today = False
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
now = datetime.now(local_tz)
|
||||||
|
|
||||||
|
if now.hour == 0 and now.minute < 5:
|
||||||
|
sent_today = False
|
||||||
|
|
||||||
|
if (
|
||||||
|
now.hour == report_hour
|
||||||
|
and now.minute >= report_minute
|
||||||
|
and not sent_today
|
||||||
|
):
|
||||||
|
logger.info("daily_weather_report: generating report...")
|
||||||
|
|
||||||
|
cities_data: List[Dict[str, Any]] = []
|
||||||
|
for city_key in TARGET_CITIES:
|
||||||
|
data = _fetch_city_data(collector, city_key)
|
||||||
|
if data:
|
||||||
|
cities_data.append(data)
|
||||||
|
|
||||||
|
if not cities_data:
|
||||||
|
logger.warning("daily_weather_report: no city data available")
|
||||||
|
sent_today = True
|
||||||
|
time.sleep(60)
|
||||||
|
continue
|
||||||
|
|
||||||
|
prompt = _build_ai_prompt(cities_data)
|
||||||
|
report_text = _call_ai(prompt)
|
||||||
|
|
||||||
|
if not report_text:
|
||||||
|
logger.warning("daily_weather_report: AI returned empty content")
|
||||||
|
sent_today = True
|
||||||
|
time.sleep(60)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
bot.send_message(
|
||||||
|
FORUM_CHAT_ID,
|
||||||
|
report_text,
|
||||||
|
message_thread_id=0,
|
||||||
|
parse_mode="HTML",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"daily_weather_report: sent successfully chars={} cities={}",
|
||||||
|
len(report_text),
|
||||||
|
len(cities_data),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("daily_weather_report: send failed: {}", exc)
|
||||||
|
|
||||||
|
sent_today = True
|
||||||
|
|
||||||
|
time.sleep(60)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"daily_weather_report: cycle error: {exc}")
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
|
def start_daily_weather_report_loop(
|
||||||
|
bot: Any, config: Dict[str, Any]
|
||||||
|
) -> threading.Thread:
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_runner,
|
||||||
|
args=(bot, config),
|
||||||
|
daemon=True,
|
||||||
|
name="daily-weather-report-loop",
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
Reference in New Issue
Block a user