diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index c8065aa6..e10a1946 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -89,6 +89,7 @@ class StartupCoordinator: self._start_weekly_reward_loop(), self._start_payment_event_loop(), self._start_payment_confirm_loop(), + self._start_daily_weather_report_loop(), ] self._runtime_status = RuntimeStatus( started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), @@ -157,7 +158,19 @@ class StartupCoordinator: details = { "mode": "airport-periodic", "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), "window": "DEB proximity ≤3°C", } @@ -168,7 +181,9 @@ class StartupCoordinator: configured_enabled=enabled, details=details, 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.config, ), @@ -222,7 +237,9 @@ class StartupCoordinator: settle_weekday = min( 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( 59, max(0, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE", 5)) ) @@ -249,9 +266,9 @@ class StartupCoordinator: configured_enabled=enabled, details=details, validation_error=validation_error, - starter=lambda: import_module("src.bot.weekly_reward_loop").start_weekly_reward_loop( - self.bot - ), + starter=lambda: import_module( + "src.bot.weekly_reward_loop" + ).start_weekly_reward_loop(self.bot), ) def _start_payment_confirm_loop(self) -> LoopStatus: @@ -318,6 +335,41 @@ class StartupCoordinator: ).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: lines = [ diff --git a/src/utils/daily_weather_report.py b/src/utils/daily_weather_report.py new file mode 100644 index 00000000..af7aeeb7 --- /dev/null +++ b/src/utils/daily_weather_report.py @@ -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. 格式:城市名 加粗,用 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