From f4fea03f3552d9c388748bba6ff0f676dc2ee836 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Thu, 12 Mar 2026 11:44:52 +0800
Subject: [PATCH] feat: Implement new bot architecture including handlers,
services, analysis modules, and comprehensive tests.
---
.env.example | 2 +
bot_listener.py | 407 +---------------------
src/analysis/market_alert_engine.py | 41 ++-
src/analysis/trend_engine.py | 76 ++--
src/bot/__init__.py | 2 +
src/bot/analysis/__init__.py | 2 +
src/bot/analysis/city_analysis_service.py | 36 ++
src/bot/analysis/deb_analysis_service.py | 220 ++++++++++++
src/bot/analysis_layer.py | 31 ++
src/bot/command_guard.py | 47 +++
src/bot/handlers/__init__.py | 2 +
src/bot/handlers/activity.py | 20 ++
src/bot/handlers/basic.py | 75 ++++
src/bot/handlers/city.py | 76 ++++
src/bot/handlers/deb.py | 71 ++++
src/bot/io_layer.py | 113 ++++++
src/bot/observability.py | 43 +++
src/bot/orchestrator.py | 93 +++++
src/bot/runtime_coordinator.py | 236 +++++++++++++
src/bot/services/__init__.py | 2 +
src/bot/services/city_command_service.py | 38 ++
src/bot/services/deb_command_service.py | 31 ++
src/bot/services/entitlement_service.py | 54 +++
src/bot/settings.py | 7 +
tests/test_bot_basic_handler.py | 55 +++
tests/test_bot_command_guard.py | 50 +++
tests/test_bot_handlers.py | 98 ++++++
tests/test_bot_runtime_coordinator.py | 42 +++
28 files changed, 1525 insertions(+), 445 deletions(-)
create mode 100644 src/bot/__init__.py
create mode 100644 src/bot/analysis/__init__.py
create mode 100644 src/bot/analysis/city_analysis_service.py
create mode 100644 src/bot/analysis/deb_analysis_service.py
create mode 100644 src/bot/analysis_layer.py
create mode 100644 src/bot/command_guard.py
create mode 100644 src/bot/handlers/__init__.py
create mode 100644 src/bot/handlers/activity.py
create mode 100644 src/bot/handlers/basic.py
create mode 100644 src/bot/handlers/city.py
create mode 100644 src/bot/handlers/deb.py
create mode 100644 src/bot/io_layer.py
create mode 100644 src/bot/observability.py
create mode 100644 src/bot/orchestrator.py
create mode 100644 src/bot/runtime_coordinator.py
create mode 100644 src/bot/services/__init__.py
create mode 100644 src/bot/services/city_command_service.py
create mode 100644 src/bot/services/deb_command_service.py
create mode 100644 src/bot/services/entitlement_service.py
create mode 100644 src/bot/settings.py
create mode 100644 tests/test_bot_basic_handler.py
create mode 100644 tests/test_bot_command_guard.py
create mode 100644 tests/test_bot_handlers.py
create mode 100644 tests/test_bot_runtime_coordinator.py
diff --git a/.env.example b/.env.example
index 2af711d4..29275f83 100644
--- a/.env.example
+++ b/.env.example
@@ -26,6 +26,8 @@ HTTP_PROXY=http://127.0.0.1:7890
LOG_LEVEL=INFO
ENV=production
POLYWEATHER_MAP_URL=https://polyweather-pro.vercel.app/
+# Bot command entitlement guard (/city, /deb). Off by default until payment API is wired.
+POLYWEATHER_BOT_REQUIRE_ENTITLEMENT=false
# Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*)
POLYWEATHER_REQUIRE_ENTITLEMENT=false
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
diff --git a/bot_listener.py b/bot_listener.py
index 2e8aa275..cbd8270d 100644
--- a/bot_listener.py
+++ b/bot_listener.py
@@ -1,416 +1,13 @@
-import sys
import os
-import telebot # type: ignore
-from loguru import logger # type: ignore
+import sys
# 确保项目根目录在 sys.path 中
project_root = os.path.dirname(os.path.abspath(__file__))
if project_root not in sys.path:
sys.path.insert(0, project_root)
-from src.utils.config_loader import load_config # type: ignore # noqa: E402
-from src.utils.telegram_push import start_trade_alert_push_loop # type: ignore # noqa: E402
-from src.onchain.polygon_wallet_watcher import start_polygon_wallet_watch_loop # type: ignore # noqa: E402
-from src.onchain.polymarket_wallet_activity_watcher import start_polymarket_wallet_activity_loop # type: ignore # noqa: E402
-from src.data_collection.weather_sources import WeatherDataCollector # type: ignore # noqa: E402
-from src.database.db_manager import DBManager
-from src.analysis.city_query_service import (
- resolve_city_name,
- build_city_query_report,
-)
-
-MESSAGE_POINTS = 4
-MESSAGE_DAILY_CAP = 50
-MESSAGE_MIN_LENGTH = 2
-MESSAGE_COOLDOWN_SEC = 30
-CITY_QUERY_COST = 1
-DEB_QUERY_COST = 1
-
-
-def start_bot():
- config = load_config()
- token = os.getenv("TELEGRAM_BOT_TOKEN")
- if not token:
- logger.error("未找到 TELEGRAM_BOT_TOKEN 环境变量")
- return
-
- bot = telebot.TeleBot(token)
- db = DBManager()
- weather = WeatherDataCollector(config)
- start_trade_alert_push_loop(bot, config)
- start_polygon_wallet_watch_loop(bot)
- start_polymarket_wallet_activity_loop(bot)
-
- def _display_name(user) -> str:
- return user.username or user.first_name or f"User_{user.id}"
-
- def _ensure_query_points(message, cost: int, label: str) -> bool:
- user = message.from_user
- db.upsert_user(user.id, _display_name(user))
- result = db.spend_points(user.id, cost)
- if result.get("ok"):
- return True
-
- balance = int(result.get("balance") or 0)
- required = int(result.get("required") or cost)
- missing = max(0, required - balance)
- bot.reply_to(
- message,
- (
- f"❌ 积分不足,无法执行 {label}\n"
- f"当前积分: {balance}\n"
- f"需要积分: {required}\n"
- f"还差积分: {missing}\n\n"
- f"积分规则:每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分,"
- f"每日上限 {MESSAGE_DAILY_CAP} 分。"
- ),
- parse_mode="HTML",
- )
- return False
-
- @bot.message_handler(commands=["start", "help"])
- def send_welcome(message):
- welcome_text = (
- "🚀 PolyWeather 天气查询机器人\n\n"
- "可用指令:\n"
- f"/city [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
- f"/deb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
- "/top - 查看积分排行榜\n"
- "/id - 获取当前聊天的 Chat ID\n\n"
- "示例: /city 伦敦\n"
- f"💡 提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分,"
- f"每日上限 {MESSAGE_DAILY_CAP} 分。"
- )
- bot.reply_to(message, welcome_text, parse_mode="HTML")
-
- @bot.message_handler(commands=["id"])
- def get_chat_id(message):
- bot.reply_to(
- message,
- f"🎯 当前聊天的 Chat ID 是: {message.chat.id}",
- parse_mode="HTML",
- )
-
- @bot.message_handler(commands=["top"])
- def show_points(message):
- """显示当前用户的积分及排行榜"""
- user = message.from_user
- db.upsert_user(user.id, _display_name(user))
- user_info = db.get_user(user.id)
-
- leaderboard = db.get_leaderboard(limit=5)
- rank_text = "🏆 PolyWeather 活跃度排行榜\n"
- rank_text += "────────────────────\n"
- for i, entry in enumerate(leaderboard):
- medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
- rank_text += f"{medal} {entry['username'][:12]}: {entry['points']} 点\n"
-
- if user_info:
- rank_text += "────────────────────\n"
- rank_text += (
- f"👤 我的状态:\n"
- f"┣ 积分: {user_info['points']}\n"
- f"┣ 发言: {user_info['message_count']} 次\n"
- f"┣ 今日发言积分: {user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}\n"
- f"┗ /city 消耗: {CITY_QUERY_COST} | /deb 消耗: {DEB_QUERY_COST}"
- )
-
- bot.send_message(message.chat.id, rank_text, parse_mode="HTML")
- @bot.message_handler(commands=["deb"])
- def deb_accuracy(message):
- """查询 DEB 融合预测的近 7 天准确率。"""
- try:
- parts = message.text.split(maxsplit=1)
- if len(parts) < 2:
- bot.reply_to(
- message,
- "❌ 用法: /deb ankara",
- parse_mode="HTML",
- )
- return
-
- from datetime import datetime as _dt, timedelta as _td
- import os as _os
-
- from src.analysis.deb_algorithm import (
- load_history,
- _is_excluded_model_name,
- reconcile_recent_actual_highs,
- )
- from src.data_collection.city_registry import ALIASES
-
- city_input = parts[1].strip().lower()
- city_name = ALIASES.get(city_input, city_input)
-
- project_root = _os.path.dirname(_os.path.abspath(__file__))
- history_file = _os.path.join(project_root, "data", "daily_records.json")
- data = load_history(history_file)
-
- if city_name not in data or not data[city_name]:
- bot.reply_to(
- message,
- f"❌ 暂无 {city_name} 的历史数据。",
- parse_mode="HTML",
- )
- return
-
- if not _ensure_query_points(message, DEB_QUERY_COST, "/deb"):
- return
-
- reconcile_info = reconcile_recent_actual_highs(city_name, lookback_days=7)
- # Reload in case reconciliation updated the file
- data = load_history(history_file)
- city_data = data[city_name]
- today = _dt.now().date()
- today_str = today.strftime("%Y-%m-%d")
- cutoff_date = today - _td(days=6)
-
- recent_items = []
- for date_str, record in city_data.items():
- try:
- row_date = _dt.strptime(date_str, "%Y-%m-%d").date()
- except Exception:
- continue
- if row_date >= cutoff_date:
- recent_items.append((date_str, record, row_date))
-
- recent_items.sort(key=lambda item: item[0])
-
- lines = [
- f"📊 DEB 准确率报告 - {city_name.title()}",
- "",
- "📅 近日记录:",
- ]
- if (
- isinstance(reconcile_info, dict)
- and reconcile_info.get("ok")
- and int(reconcile_info.get("updated") or 0) > 0
- ):
- lines.extend(
- [
- f"🔁 已用 METAR 历史回填修正 {int(reconcile_info.get('updated'))} 天实测最高温",
- "",
- ]
- )
- total_days = 0
- hits = 0
- deb_errors = []
- signed_errors = []
- model_errors = {}
-
- for date_str, record, _row_date in recent_items:
- actual = record.get("actual_high")
- deb_pred = record.get("deb_prediction")
- forecasts = record.get("forecasts", {}) or {}
-
- if actual is None:
- continue
-
- try:
- actual = float(actual)
- if deb_pred is not None:
- deb_pred = float(deb_pred)
- except Exception:
- continue
-
- if deb_pred is None and forecasts:
- valid_preds = [
- float(v)
- for k, v in forecasts.items()
- if v is not None and not _is_excluded_model_name(k)
- ]
- if valid_preds:
- deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
-
- actual_wu = round(actual)
-
- if date_str == today_str:
- lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual:.1f})")
- elif deb_pred is not None:
- total_days += 1
- deb_wu = round(deb_pred)
- hit = deb_wu == actual_wu
- if hit:
- hits += 1
- err = deb_pred - actual
- deb_errors.append(abs(err))
- signed_errors.append(err)
-
- if hit:
- result_icon = "✅"
- err_text = f"偏差{abs(err):.1f}°"
- elif err < 0:
- result_icon = "❌"
- err_text = f"低估{abs(err):.1f}°"
- else:
- result_icon = "❌"
- err_text = f"高估{abs(err):.1f}°"
-
- retro = "≈" if "deb_prediction" not in record else ""
- lines.append(
- f" {date_str}: DEB {retro}{deb_pred:.1f}→{deb_wu} vs 实测 {actual:.1f}→{actual_wu} "
- f"{result_icon} {err_text}"
- )
-
- if date_str != today_str and actual is not None:
- for model, pred in forecasts.items():
- if _is_excluded_model_name(model):
- continue
- if pred is None:
- continue
- try:
- model_errors.setdefault(model, []).append(abs(float(pred) - actual))
- except Exception:
- continue
-
- if total_days > 0:
- hit_rate = hits / total_days * 100
- deb_mae = sum(deb_errors) / len(deb_errors)
- lines.append("")
- lines.append(
- f"🏁 DEB 总战绩:WU命中 {hits}/{total_days} ({hit_rate:.0f}%) | MAE: {deb_mae:.1f}°"
- )
-
- if model_errors:
- lines.append("")
- lines.append("📈 模型 MAE 对比:")
- model_maes = {m: sum(e) / len(e) for m, e in model_errors.items() if e}
- sorted_models = sorted(model_maes.items(), key=lambda item: item[1])
- for model, mae in sorted_models:
- tag = " ⭐" if mae <= deb_mae else ""
- lines.append(f" {model}: {mae:.1f}°{tag}")
- lines.append(f" DEB融合: {deb_mae:.1f}°")
-
- mean_bias = sum(signed_errors) / len(signed_errors)
- underest = sum(1 for e in signed_errors if e < -0.3)
- overest = sum(1 for e in signed_errors if e > 0.3)
- accurate = total_days - underest - overest
-
- lines.append("")
- lines.append("🔍 偏差分析:")
- if abs(mean_bias) > 0.3:
- bias_label = "系统性低估" if mean_bias < 0 else "系统性高估"
- lines.append(f" ⚠️ {bias_label}:平均偏差 {mean_bias:+.1f}°")
- else:
- lines.append(f" ✅ 整体无明显系统偏差:平均偏差 {mean_bias:+.1f}°")
- lines.append(f" (低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate} 次)")
-
- lines.append("")
- lines.append("💡 建议:")
- if underest > overest and abs(mean_bias) > 0.5:
- lines.append(
- f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 "
- f"{abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。"
- )
- elif overest > underest and abs(mean_bias) > 0.5:
- lines.append(
- f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能低于 DEB 融合值。交易时注意追高风险。"
- )
- elif deb_mae > 1.5:
- lines.append(f" 近期模型波动较大(MAE {deb_mae:.1f}°),建议降低对单一日预测的信任度。")
- elif hit_rate >= 60:
- lines.append(" DEB 近期表现稳定,可继续作为主要参考。")
- else:
- lines.append(" 近期准确率一般,建议结合主站实测与周边站点共同判断。")
-
- lines.append("")
- lines.append("📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
- lines.append("📅 统计窗口:近7天滚动样本。")
- else:
- lines.append("")
- lines.append("🔔 近 7 天尚无完整的 DEB 预测记录。")
-
- lines.append("")
- lines.append(f"💸 本次消耗 {DEB_QUERY_COST} 积分。")
- bot.reply_to(message, "\n".join(lines), parse_mode="HTML")
- except Exception as e:
- bot.reply_to(message, f"❌ 查询失败: {e}")
-
- @bot.message_handler(commands=["city"])
- def get_city_info(message):
- """查询指定城市的天气详情"""
- try:
- parts = message.text.split(maxsplit=1)
- if len(parts) < 2:
- bot.reply_to(
- message,
- "❌ 请输入城市名称\n\n用法: /city chicago",
- parse_mode="HTML",
- )
- return
-
- city_input = parts[1].strip().lower()
- city_name, supported_cities = resolve_city_name(city_input)
- if not city_name:
- city_list = ", ".join(supported_cities)
- bot.reply_to(
- message,
- f"❌ 未找到城市: {city_input}\n\n支持的城市: {city_list}",
- parse_mode="HTML",
- )
- return
-
- if not _ensure_query_points(message, CITY_QUERY_COST, "/city"):
- return
-
- bot.send_message(
- message.chat.id, f"🔍 正在查询 {city_name.title()} 的天气数据..."
- )
-
- coords = weather.get_coordinates(city_name)
- if not coords:
- bot.reply_to(message, f"❌ 未找到城市坐标: {city_name}")
- return
-
- weather_data = weather.fetch_all_sources(
- city_name,
- lat=coords["lat"],
- lon=coords["lon"],
- force_refresh=True,
- )
- city_report = build_city_query_report(
- city_name=city_name,
- weather_data=weather_data,
- city_query_cost=CITY_QUERY_COST,
- )
- bot.send_message(message.chat.id, city_report, parse_mode="HTML")
- except Exception as e:
- import traceback
-
- logger.error(f"查询失败: {e}\n{traceback.format_exc()}")
- bot.reply_to(message, f"❌ 查询失败: {e}")
-
- @bot.message_handler(func=lambda message: True, content_types=['text'])
- def track_activity(message):
- """全量监听消息,用于记录群内发言积分(非指令消息)"""
- if message.text.startswith('/'):
- return
- if message.chat.type not in ("group", "supergroup"):
- return
-
- user = message.from_user
- username = _display_name(user)
- db.upsert_user(user.id, username)
-
- result = db.add_message_activity(
- user.id,
- text=message.text,
- points_to_add=MESSAGE_POINTS,
- cooldown_sec=MESSAGE_COOLDOWN_SEC,
- daily_cap=MESSAGE_DAILY_CAP,
- min_text_length=MESSAGE_MIN_LENGTH,
- )
- if result.get("awarded"):
- logger.info(
- f"message points awarded user={user.id} points=+{MESSAGE_POINTS} "
- f"daily_points={result.get('daily_points')}/{MESSAGE_DAILY_CAP}"
- )
-
- logger.info("🤖 Bot 启动中...")
- bot.infinity_polling()
+from src.bot.orchestrator import start_bot # noqa: E402
if __name__ == "__main__":
start_bot()
-
-
diff --git a/src/analysis/market_alert_engine.py b/src/analysis/market_alert_engine.py
index 166d3991..8962940c 100644
--- a/src/analysis/market_alert_engine.py
+++ b/src/analysis/market_alert_engine.py
@@ -1009,6 +1009,8 @@ def _build_telegram_messages_mispricing(
city_weather: Dict[str, Any],
rules: Dict[str, Dict[str, Any]],
market_snapshot: Optional[Dict[str, Any]] = None,
+ suppression: Optional[Dict[str, Any]] = None,
+ map_url: Optional[str] = None,
) -> Dict[str, str]:
temp_symbol = str(city_weather.get("temp_symbol") or "°C")
city_name = city_weather.get("display_name") or city_weather.get("name", "").title()
@@ -1019,8 +1021,12 @@ def _build_telegram_messages_mispricing(
snapshot = market_snapshot or _extract_market_snapshot(city_weather)
momentum = rules.get("momentum_spike", {})
+ center_deb = rules.get("ankara_center_deb_hit", {})
local_time = str(city_weather.get("local_time") or "").strip()
obs_time = str(current.get("obs_time") or "").strip()
+ suppressed = bool((suppression or {}).get("suppressed"))
+ has_active_trigger = any(rule.get("triggered") for rule in rules.values())
+ types_cn = "高温已过(暂停推送)" if suppressed else (_join_trigger_types_cn(rules) or "天气状态快照")
delta_temp = _sf(momentum.get("delta_temp"))
delta_min = momentum.get("delta_minutes")
@@ -1055,9 +1061,34 @@ def _build_telegram_messages_mispricing(
or snapshot.get("primary_market_url")
or ""
).strip()
+ final_map = map_url or "https://polyweather-pro.vercel.app/"
+ advice = _build_advice_cn(rules, temp_symbol, suppression=suppression)
- lines_zh = [f"🚨 PolyWeather 错价雷达 [{city_name}]"]
+ center_line = ""
+ if center_deb.get("triggered"):
+ center_station = center_deb.get("center_station") or {}
+ center_name = center_station.get("name") or "Ankara Center"
+ center_temp = _sf(center_station.get("temp"))
+ deb_prediction = _sf(center_deb.get("deb_prediction"))
+ airport_temp = _sf(center_deb.get("airport_temp"))
+ lead_gap = _sf(center_deb.get("center_lead_vs_airport"))
+ if center_temp is not None and deb_prediction is not None:
+ center_line = (
+ f"Center信号:{center_name} {center_temp:.1f}{temp_symbol} 已达到 DEB {deb_prediction:.1f}{temp_symbol}"
+ )
+ if airport_temp is not None:
+ center_line += f" | 机场 {airport_temp:.1f}{temp_symbol}"
+ if lead_gap is not None:
+ center_line += f" | 领先 {lead_gap:+.1f}{temp_symbol}"
+
+ if snapshot.get("available"):
+ title_zh = "🚨 PolyWeather 错价雷达"
+ else:
+ title_zh = "🚨 PolyWeather 异动预警" if (has_active_trigger or suppressed) else "📍 PolyWeather 状态快照"
+
+ lines_zh = [f"{title_zh} [{city_name}]"]
lines_zh.append("")
+ lines_zh.append(f"类型:{types_cn}")
if anchor_high_c is not None and anchor_settle is not None:
if anchor_model:
lines_zh.append(
@@ -1071,6 +1102,8 @@ def _build_telegram_messages_mispricing(
lines_zh.append("基准:多模型最高温 --(结算参考 --)")
lines_zh.append(f"命中桶:{match_bucket_label} | Yes: {match_bucket_yes}")
lines_zh.append("触发:该桶 Yes 价格 < 10c,疑似低估")
+ if center_line:
+ lines_zh.append(center_line)
lines_zh.append("")
lines_zh.append(f"动态:{dynamic_text}")
if local_time or obs_time:
@@ -1080,17 +1113,21 @@ def _build_telegram_messages_mispricing(
lines_zh.append(f"时间:当地 {local_time}")
else:
lines_zh.append(f"时间:观测 {obs_time}")
+ lines_zh.append(f"建议:{advice}")
lines_zh.append("")
if market_url:
lines_zh.append(f"市场链接:{market_url}")
+ lines_zh.append(f"地图:{final_map}")
lines_en = [
f"🚨 PolyWeather Mispricing Radar [{city_name}]",
"",
+ f"Type: {types_cn}",
f"Now: {dynamic_text}",
]
if market_url:
lines_en.append(f"Market link: {market_url}")
+ lines_en.append(f"Map: {final_map}")
return {"zh": "\n".join(lines_zh), "en": "\n".join(lines_en)}
@@ -1297,6 +1334,8 @@ def build_trading_alerts(
city_weather=city_weather,
rules=rules,
market_snapshot=market_snapshot,
+ suppression=suppression,
+ map_url=map_url,
)
evidence = _build_alert_evidence(
city_weather=city_weather,
diff --git a/src/analysis/trend_engine.py b/src/analysis/trend_engine.py
index 98e8033f..13406842 100644
--- a/src/analysis/trend_engine.py
+++ b/src/analysis/trend_engine.py
@@ -385,34 +385,43 @@ def analyze_weather_trend(
probabilities: List[Dict[str, Any]] = []
forecast_miss_deg = 0.0
- if (ens_p10 is not None and ens_p90 is not None) or fallback_sigma:
- if not is_dead_market:
- # Forecast miss magnitude
- if max_so_far is not None and forecast_median is not None:
- forecast_miss_deg = round(forecast_median - max_so_far, 1)
-
- fallback_center = forecast_median if forecast_median is not None else (forecast_high if forecast_high is not None else cur_temp)
- center = ens_median if ens_median is not None else fallback_center
-
- # Reality-anchored μ
- if (
- max_so_far is not None
- and forecast_median is not None
- and peak_status in ("past", "in_window")
- and max_so_far < forecast_median - 2.0
- ):
- if is_cooling or peak_status == "past":
- mu = max_so_far
- else:
- mu = max_so_far + 0.5
+ if is_dead_market:
+ settled_wu = wu_round(max_so_far) if max_so_far is not None else 0
+ dead_msg = f"🎲 结算预测:已锁定 {settled_wu}{temp_symbol} (死盘确认)"
+ insights.append(dead_msg)
+ ai_features.append("🎲 状态: 确认死盘,结算已无悬念。")
+ if max_so_far is not None:
+ mu = max_so_far
+ probabilities = [
+ {"value": settled_wu, "range": f"[{settled_wu-0.5}~{settled_wu+0.5})", "probability": 1.0}
+ ]
+ elif (ens_p10 is not None and ens_p90 is not None) or fallback_sigma:
+ # Forecast miss magnitude
+ if max_so_far is not None and forecast_median is not None:
+ forecast_miss_deg = round(forecast_median - max_so_far, 1)
+
+ fallback_center = forecast_median if forecast_median is not None else (forecast_high if forecast_high is not None else cur_temp)
+ center = ens_median if ens_median is not None else fallback_center
+
+ # Reality-anchored μ
+ if (
+ max_so_far is not None
+ and forecast_median is not None
+ and peak_status in ("past", "in_window")
+ and max_so_far < forecast_median - 2.0
+ ):
+ if is_cooling or peak_status == "past":
+ mu = max_so_far
else:
- mu = (
- forecast_median * 0.7 + center * 0.3
- if forecast_median is not None and center is not None
- else center
- )
- if max_so_far is not None and mu is not None and max_so_far > mu:
- mu = max_so_far + (0.3 if not is_cooling else 0.0)
+ mu = max_so_far + 0.5
+ else:
+ mu = (
+ forecast_median * 0.7 + center * 0.3
+ if forecast_median is not None and center is not None
+ else center
+ )
+ if max_so_far is not None and mu is not None and max_so_far > mu:
+ mu = max_so_far + (0.3 if not is_cooling else 0.0)
# Forecast miss severity for AI
if forecast_miss_deg > 2.0 and peak_status in ("past", "in_window"):
@@ -431,7 +440,7 @@ def analyze_weather_trend(
mu = probs_result.get("mu", mu)
probabilities = probs_result.get("probabilities", [])
sorted_probs = probs_result.get("sorted_probs", [])
-
+
if sorted_probs:
prob_parts = [
f"{int(t)}{temp_symbol} [{t - 0.5}~{t + 0.5}) {p * 100:.0f}%"
@@ -442,17 +451,6 @@ def analyze_weather_trend(
insights.append(f"🎲 结算概率 (μ={mu:.1f}):{prob_str}")
ai_features.append(f"🎲 数学概率分布:{prob_str}")
- elif is_dead_market:
- settled_wu = wu_round(max_so_far) if max_so_far is not None else 0
- dead_msg = f"🎲 结算预测:已锁定 {settled_wu}{temp_symbol} (死盘确认)"
- insights.append(dead_msg)
- ai_features.append("🎲 状态: 确认死盘,结算已无悬念。")
- if max_so_far is not None:
- mu = max_so_far
- probabilities = [
- {"value": settled_wu, "range": f"[{settled_wu-0.5}~{settled_wu+0.5})", "probability": 1.0}
- ]
-
# === Actual exceeds forecast ===
if max_so_far is not None and forecast_high is not None:
if max_so_far > forecast_high + 0.5:
diff --git a/src/bot/__init__.py b/src/bot/__init__.py
new file mode 100644
index 00000000..46ce5319
--- /dev/null
+++ b/src/bot/__init__.py
@@ -0,0 +1,2 @@
+"""Telegram bot composition layers."""
+
diff --git a/src/bot/analysis/__init__.py b/src/bot/analysis/__init__.py
new file mode 100644
index 00000000..881e175d
--- /dev/null
+++ b/src/bot/analysis/__init__.py
@@ -0,0 +1,2 @@
+"""Bot analysis services."""
+
diff --git a/src/bot/analysis/city_analysis_service.py b/src/bot/analysis/city_analysis_service.py
new file mode 100644
index 00000000..06b51699
--- /dev/null
+++ b/src/bot/analysis/city_analysis_service.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from typing import Any
+
+
+class CityAnalysisService:
+ """City analysis adapter with lazy imports to trim cold startup."""
+
+ def __init__(self, weather: Any):
+ self.weather = weather
+
+ def resolve_city(self, city_input: str):
+ from src.analysis.city_query_service import resolve_city_name
+
+ return resolve_city_name(city_input)
+
+ def build_city_report(self, city_name: str, city_query_cost: int) -> str:
+ coords = self.weather.get_coordinates(city_name)
+ if not coords:
+ raise ValueError(f"未找到城市坐标: {city_name}")
+
+ weather_data = self.weather.fetch_all_sources(
+ city_name,
+ lat=coords["lat"],
+ lon=coords["lon"],
+ force_refresh=True,
+ )
+
+ from src.analysis.city_query_service import build_city_query_report
+
+ return build_city_query_report(
+ city_name=city_name,
+ weather_data=weather_data,
+ city_query_cost=city_query_cost,
+ )
+
diff --git a/src/bot/analysis/deb_analysis_service.py b/src/bot/analysis/deb_analysis_service.py
new file mode 100644
index 00000000..d588a0f8
--- /dev/null
+++ b/src/bot/analysis/deb_analysis_service.py
@@ -0,0 +1,220 @@
+from __future__ import annotations
+
+import os
+from datetime import datetime as _dt
+from datetime import timedelta as _td
+
+
+class DebAnalysisService:
+ """DEB analytics adapter with lazy imports to trim cold startup."""
+
+ def __init__(self, project_root: str):
+ self.project_root = project_root
+ self.history_file = os.path.join(project_root, "data", "daily_records.json")
+
+ @staticmethod
+ def _load_aliases() -> dict[str, str]:
+ from src.data_collection.city_registry import ALIASES
+
+ return ALIASES
+
+ @staticmethod
+ def _load_deb_module_api():
+ from src.analysis.deb_algorithm import (
+ _is_excluded_model_name,
+ load_history,
+ reconcile_recent_actual_highs,
+ )
+
+ return _is_excluded_model_name, load_history, reconcile_recent_actual_highs
+
+ def resolve_city(self, city_input: str) -> str:
+ aliases = self._load_aliases()
+ city_input_norm = city_input.strip().lower()
+ return aliases.get(city_input_norm, city_input_norm)
+
+ def has_history(self, city_name: str) -> bool:
+ _is_excluded_model_name, load_history, reconcile_recent_actual_highs = (
+ self._load_deb_module_api()
+ )
+ del _is_excluded_model_name, reconcile_recent_actual_highs
+ data = load_history(self.history_file)
+ city_data = data.get(city_name)
+ return isinstance(city_data, dict) and bool(city_data)
+
+ def build_deb_accuracy_report(self, city_name: str, deb_query_cost: int) -> str:
+ _is_excluded_model_name, load_history, reconcile_recent_actual_highs = (
+ self._load_deb_module_api()
+ )
+ data = load_history(self.history_file)
+ if city_name not in data or not data[city_name]:
+ raise ValueError(f"暂无 {city_name} 的历史数据。")
+
+ reconcile_info = reconcile_recent_actual_highs(city_name, lookback_days=7)
+ data = load_history(self.history_file)
+ city_data = data[city_name]
+ today = _dt.now().date()
+ today_str = today.strftime("%Y-%m-%d")
+ cutoff_date = today - _td(days=6)
+
+ recent_items = []
+ for date_str, record in city_data.items():
+ try:
+ row_date = _dt.strptime(date_str, "%Y-%m-%d").date()
+ except Exception:
+ continue
+ if row_date >= cutoff_date:
+ recent_items.append((date_str, record, row_date))
+
+ recent_items.sort(key=lambda item: item[0])
+
+ lines = [
+ f"📊 DEB 准确率报告 - {city_name.title()}",
+ "",
+ "📅 近日记录:",
+ ]
+ if (
+ isinstance(reconcile_info, dict)
+ and reconcile_info.get("ok")
+ and int(reconcile_info.get("updated") or 0) > 0
+ ):
+ lines.extend(
+ [
+ f"🔁 已用 METAR 历史回填修正 {int(reconcile_info.get('updated'))} 天实测最高温",
+ "",
+ ]
+ )
+ total_days = 0
+ hits = 0
+ deb_errors = []
+ signed_errors = []
+ model_errors: dict[str, list[float]] = {}
+
+ for date_str, record, _row_date in recent_items:
+ actual = record.get("actual_high")
+ deb_pred = record.get("deb_prediction")
+ forecasts = record.get("forecasts", {}) or {}
+
+ if actual is None:
+ continue
+
+ try:
+ actual = float(actual)
+ if deb_pred is not None:
+ deb_pred = float(deb_pred)
+ except Exception:
+ continue
+
+ if deb_pred is None and forecasts:
+ valid_preds = [
+ float(v)
+ for k, v in forecasts.items()
+ if v is not None and not _is_excluded_model_name(k)
+ ]
+ if valid_preds:
+ deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
+
+ actual_wu = round(actual)
+
+ if date_str == today_str:
+ lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual:.1f})")
+ elif deb_pred is not None:
+ total_days += 1
+ deb_wu = round(deb_pred)
+ hit = deb_wu == actual_wu
+ if hit:
+ hits += 1
+ err = deb_pred - actual
+ deb_errors.append(abs(err))
+ signed_errors.append(err)
+
+ if hit:
+ result_icon = "✅"
+ err_text = f"偏差{abs(err):.1f}°"
+ elif err < 0:
+ result_icon = "❌"
+ err_text = f"低估{abs(err):.1f}°"
+ else:
+ result_icon = "❌"
+ err_text = f"高估{abs(err):.1f}°"
+
+ retro = "≈" if "deb_prediction" not in record else ""
+ lines.append(
+ f" {date_str}: DEB {retro}{deb_pred:.1f}→{deb_wu} vs 实测 {actual:.1f}→{actual_wu} "
+ f"{result_icon} {err_text}"
+ )
+
+ if date_str != today_str and actual is not None:
+ for model, pred in forecasts.items():
+ if _is_excluded_model_name(model):
+ continue
+ if pred is None:
+ continue
+ try:
+ model_errors.setdefault(model, []).append(abs(float(pred) - actual))
+ except Exception:
+ continue
+
+ if total_days > 0:
+ hit_rate = hits / total_days * 100
+ deb_mae = sum(deb_errors) / len(deb_errors)
+ lines.append("")
+ lines.append(
+ f"🏁 DEB 总战绩:WU命中 {hits}/{total_days} ({hit_rate:.0f}%) | MAE: {deb_mae:.1f}°"
+ )
+
+ if model_errors:
+ lines.append("")
+ lines.append("📈 模型 MAE 对比:")
+ model_maes = {m: sum(e) / len(e) for m, e in model_errors.items() if e}
+ sorted_models = sorted(model_maes.items(), key=lambda item: item[1])
+ for model, mae in sorted_models:
+ tag = " ⭐" if mae <= deb_mae else ""
+ lines.append(f" {model}: {mae:.1f}°{tag}")
+ lines.append(f" DEB融合: {deb_mae:.1f}°")
+
+ mean_bias = sum(signed_errors) / len(signed_errors)
+ underest = sum(1 for e in signed_errors if e < -0.3)
+ overest = sum(1 for e in signed_errors if e > 0.3)
+ accurate = total_days - underest - overest
+
+ lines.append("")
+ lines.append("🔍 偏差分析:")
+ if abs(mean_bias) > 0.3:
+ bias_label = "系统性低估" if mean_bias < 0 else "系统性高估"
+ lines.append(f" ⚠️ {bias_label}:平均偏差 {mean_bias:+.1f}°")
+ else:
+ lines.append(f" ✅ 整体无明显系统偏差:平均偏差 {mean_bias:+.1f}°")
+ lines.append(f" (低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate} 次)")
+
+ lines.append("")
+ lines.append("💡 建议:")
+ if underest > overest and abs(mean_bias) > 0.5:
+ lines.append(
+ f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 "
+ f"{abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。"
+ )
+ elif overest > underest and abs(mean_bias) > 0.5:
+ lines.append(
+ f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能低于 DEB 融合值。交易时注意追高风险。"
+ )
+ elif deb_mae > 1.5:
+ lines.append(
+ f" 近期模型波动较大(MAE {deb_mae:.1f}°),建议降低对单一日预测的信任度。"
+ )
+ elif hit_rate >= 60:
+ lines.append(" DEB 近期表现稳定,可继续作为主要参考。")
+ else:
+ lines.append(" 近期准确率一般,建议结合主站实测与周边站点共同判断。")
+
+ lines.append("")
+ lines.append("📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
+ lines.append("📅 统计窗口:近7天滚动样本。")
+ else:
+ lines.append("")
+ lines.append("🔔 近 7 天尚无完整的 DEB 预测记录。")
+
+ lines.append("")
+ lines.append(f"💸 本次消耗 {deb_query_cost} 积分。")
+ return "\n".join(lines)
+
diff --git a/src/bot/analysis_layer.py b/src/bot/analysis_layer.py
new file mode 100644
index 00000000..834f0cca
--- /dev/null
+++ b/src/bot/analysis_layer.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from src.bot.analysis.city_analysis_service import CityAnalysisService
+from src.bot.analysis.deb_analysis_service import DebAnalysisService
+
+
+class BotAnalysisLayer:
+ """
+ Backward-compatible facade.
+ New code should use CityAnalysisService / DebAnalysisService directly.
+ """
+
+ def __init__(self, weather, project_root: str):
+ self._city = CityAnalysisService(weather=weather)
+ self._deb = DebAnalysisService(project_root=project_root)
+
+ def resolve_city(self, city_input: str):
+ return self._city.resolve_city(city_input)
+
+ def build_city_report(self, city_name: str, city_query_cost: int) -> str:
+ return self._city.build_city_report(city_name, city_query_cost)
+
+ def resolve_deb_city(self, city_input: str) -> str:
+ return self._deb.resolve_city(city_input)
+
+ def has_deb_history(self, city_name: str) -> bool:
+ return self._deb.has_history(city_name)
+
+ def build_deb_accuracy_report(self, city_name: str, deb_query_cost: int) -> str:
+ return self._deb.build_deb_accuracy_report(city_name, deb_query_cost)
+
diff --git a/src/bot/command_guard.py b/src/bot/command_guard.py
new file mode 100644
index 00000000..1e9f60b7
--- /dev/null
+++ b/src/bot/command_guard.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from typing import Any
+
+from loguru import logger
+
+from src.bot.io_layer import BotIOLayer
+from src.bot.services.entitlement_service import BotEntitlementService
+
+
+class CommandGuard:
+ """Unified command gate: entitlement + points charge."""
+
+ def __init__(self, io_layer: BotIOLayer, entitlement_service: BotEntitlementService):
+ self.io_layer = io_layer
+ self.entitlement_service = entitlement_service
+
+ def ensure_entitled(self, message: Any, command_label: str) -> bool:
+ user = getattr(message, "from_user", None)
+ user_id = getattr(user, "id", None)
+ if user_id is None:
+ return False
+
+ decision = self.entitlement_service.check(int(user_id), command_label)
+ if decision.allowed:
+ return True
+
+ self.io_layer.bot.reply_to(
+ message,
+ (
+ "🔒 当前指令需要高级权限。\n"
+ "请先开通订阅后再使用。"
+ ),
+ )
+ logger.info(
+ "bot entitlement blocked command={} user_id={} reason={}",
+ command_label,
+ user_id,
+ decision.reason,
+ )
+ return False
+
+ def ensure_access_and_points(self, message: Any, cost: int, command_label: str) -> bool:
+ if not self.ensure_entitled(message, command_label):
+ return False
+ return self.io_layer.ensure_query_points(message, cost, command_label)
+
diff --git a/src/bot/handlers/__init__.py b/src/bot/handlers/__init__.py
new file mode 100644
index 00000000..ec2a04e3
--- /dev/null
+++ b/src/bot/handlers/__init__.py
@@ -0,0 +1,2 @@
+"""Bot command handlers."""
+
diff --git a/src/bot/handlers/activity.py b/src/bot/handlers/activity.py
new file mode 100644
index 00000000..de4b6f34
--- /dev/null
+++ b/src/bot/handlers/activity.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from typing import Any
+
+from src.bot.io_layer import BotIOLayer
+
+
+class ActivityHandler:
+ def __init__(self, bot: Any, io_layer: BotIOLayer):
+ self.bot = bot
+ self.io_layer = io_layer
+
+ def register(self) -> None:
+ @self.bot.message_handler(func=lambda message: True, content_types=["text"])
+ def _activity(message):
+ self.handle(message)
+
+ def handle(self, message: Any) -> None:
+ self.io_layer.track_group_text_activity(message)
+
diff --git a/src/bot/handlers/basic.py b/src/bot/handlers/basic.py
new file mode 100644
index 00000000..e74f2c42
--- /dev/null
+++ b/src/bot/handlers/basic.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from typing import Any
+from typing import Callable
+
+from src.bot.io_layer import BotIOLayer
+from src.bot.observability import CommandTrace
+from src.bot.runtime_coordinator import RuntimeStatus, render_runtime_status_html
+
+
+class BasicCommandHandler:
+ def __init__(
+ self,
+ bot: Any,
+ io_layer: BotIOLayer,
+ runtime_status_provider: Callable[[], RuntimeStatus],
+ ):
+ self.bot = bot
+ self.io_layer = io_layer
+ self.runtime_status_provider = runtime_status_provider
+
+ def register(self) -> None:
+ @self.bot.message_handler(commands=["start", "help"])
+ def _start_help(message):
+ self.handle_start_help(message)
+
+ @self.bot.message_handler(commands=["id"])
+ def _id(message):
+ self.handle_id(message)
+
+ @self.bot.message_handler(commands=["top"])
+ def _top(message):
+ self.handle_top(message)
+
+ @self.bot.message_handler(commands=["diag"])
+ def _diag(message):
+ self.handle_diag(message)
+
+ def handle_start_help(self, message: Any) -> None:
+ trace = CommandTrace("/start", message)
+ try:
+ self.bot.reply_to(message, self.io_layer.build_welcome_text(), parse_mode="HTML")
+ trace.set_status("ok")
+ finally:
+ trace.emit()
+
+ def handle_id(self, message: Any) -> None:
+ trace = CommandTrace("/id", message)
+ try:
+ self.bot.reply_to(
+ message,
+ f"🎯 当前聊天的 Chat ID 是: {message.chat.id}",
+ parse_mode="HTML",
+ )
+ trace.set_status("ok")
+ finally:
+ trace.emit()
+
+ def handle_top(self, message: Any) -> None:
+ trace = CommandTrace("/top", message)
+ try:
+ rank_text = self.io_layer.build_points_rank_text(message.from_user)
+ self.bot.send_message(message.chat.id, rank_text, parse_mode="HTML")
+ trace.set_status("ok")
+ finally:
+ trace.emit()
+
+ def handle_diag(self, message: Any) -> None:
+ trace = CommandTrace("/diag", message)
+ try:
+ status = self.runtime_status_provider()
+ self.bot.reply_to(message, render_runtime_status_html(status), parse_mode="HTML")
+ trace.set_status("ok")
+ finally:
+ trace.emit()
diff --git a/src/bot/handlers/city.py b/src/bot/handlers/city.py
new file mode 100644
index 00000000..1e429b48
--- /dev/null
+++ b/src/bot/handlers/city.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+from typing import Any
+
+from loguru import logger
+
+from src.bot.command_guard import CommandGuard
+from src.bot.observability import CommandTrace
+from src.bot.services.city_command_service import CityCommandService
+from src.bot.settings import CITY_QUERY_COST
+
+
+class CityCommandHandler:
+ def __init__(
+ self,
+ bot: Any,
+ guard: CommandGuard,
+ city_service: CityCommandService,
+ ):
+ self.bot = bot
+ self.guard = guard
+ self.city_service = city_service
+
+ def register(self) -> None:
+ @self.bot.message_handler(commands=["city"])
+ def _city(message):
+ self.handle(message)
+
+ def handle(self, message: Any) -> None:
+ trace = CommandTrace("/city", message)
+ try:
+ parts = (message.text or "").split(maxsplit=1)
+ if len(parts) < 2:
+ trace.set_status("bad_request", "missing_city")
+ self.bot.reply_to(
+ message,
+ "❌ 请输入城市名称\n\n用法: /city chicago",
+ parse_mode="HTML",
+ )
+ return
+
+ city_input = parts[1].strip().lower()
+ resolved = self.city_service.resolve_city(city_input)
+ if not resolved.ok:
+ city_list = ", ".join(resolved.supported_cities or [])
+ trace.set_status("bad_request", "city_not_supported")
+ self.bot.reply_to(
+ message,
+ f"❌ 未找到城市: {city_input}\n\n支持的城市: {city_list}",
+ parse_mode="HTML",
+ )
+ return
+
+ city_name = str(resolved.city_name)
+ if not self.guard.ensure_access_and_points(message, CITY_QUERY_COST, "/city"):
+ trace.set_status("blocked", "guard_rejected")
+ return
+
+ self.bot.send_message(
+ message.chat.id, f"🔍 正在查询 {city_name.title()} 的天气数据..."
+ )
+ report_result = self.city_service.build_report(city_name, CITY_QUERY_COST)
+ if not report_result.ok:
+ trace.set_status("failed", report_result.error or "city_report_failed")
+ self.bot.reply_to(message, f"❌ 查询失败: {report_result.error}")
+ return
+
+ self.bot.send_message(message.chat.id, str(report_result.report), parse_mode="HTML")
+ trace.set_status("ok", city_name)
+ except Exception as exc:
+ trace.set_status("failed", "unexpected_error")
+ logger.exception("查询 /city 失败")
+ self.bot.reply_to(message, f"❌ 查询失败: {exc}")
+ finally:
+ trace.emit()
+
diff --git a/src/bot/handlers/deb.py b/src/bot/handlers/deb.py
new file mode 100644
index 00000000..0056d3aa
--- /dev/null
+++ b/src/bot/handlers/deb.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from typing import Any
+
+from loguru import logger
+
+from src.bot.command_guard import CommandGuard
+from src.bot.observability import CommandTrace
+from src.bot.services.deb_command_service import DebCommandService
+from src.bot.settings import DEB_QUERY_COST
+
+
+class DebCommandHandler:
+ def __init__(
+ self,
+ bot: Any,
+ guard: CommandGuard,
+ deb_service: DebCommandService,
+ ):
+ self.bot = bot
+ self.guard = guard
+ self.deb_service = deb_service
+
+ def register(self) -> None:
+ @self.bot.message_handler(commands=["deb"])
+ def _deb(message):
+ self.handle(message)
+
+ def handle(self, message: Any) -> None:
+ trace = CommandTrace("/deb", message)
+ try:
+ parts = (message.text or "").split(maxsplit=1)
+ if len(parts) < 2:
+ trace.set_status("bad_request", "missing_city")
+ self.bot.reply_to(
+ message,
+ "❌ 用法: /deb ankara",
+ parse_mode="HTML",
+ )
+ return
+
+ city_input = parts[1].strip().lower()
+ city_name = self.deb_service.resolve_city(city_input)
+ if not self.deb_service.has_history(city_name):
+ trace.set_status("bad_request", "history_missing")
+ self.bot.reply_to(
+ message,
+ f"❌ 暂无 {city_name} 的历史数据。",
+ parse_mode="HTML",
+ )
+ return
+
+ if not self.guard.ensure_access_and_points(message, DEB_QUERY_COST, "/deb"):
+ trace.set_status("blocked", "guard_rejected")
+ return
+
+ report_result = self.deb_service.build_report(city_name, DEB_QUERY_COST)
+ if not report_result.ok:
+ trace.set_status("failed", report_result.error or "deb_report_failed")
+ self.bot.reply_to(message, f"❌ 查询失败: {report_result.error}")
+ return
+
+ self.bot.reply_to(message, str(report_result.report), parse_mode="HTML")
+ trace.set_status("ok", city_name)
+ except Exception as exc:
+ trace.set_status("failed", "unexpected_error")
+ logger.exception("查询 /deb 失败")
+ self.bot.reply_to(message, f"❌ 查询失败: {exc}")
+ finally:
+ trace.emit()
+
diff --git a/src/bot/io_layer.py b/src/bot/io_layer.py
new file mode 100644
index 00000000..b2e7dd01
--- /dev/null
+++ b/src/bot/io_layer.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+from typing import Any
+
+from loguru import logger
+
+from src.bot.settings import (
+ CITY_QUERY_COST,
+ DEB_QUERY_COST,
+ MESSAGE_COOLDOWN_SEC,
+ MESSAGE_DAILY_CAP,
+ MESSAGE_MIN_LENGTH,
+ MESSAGE_POINTS,
+)
+from src.database.db_manager import DBManager
+
+
+class BotIOLayer:
+ """Telegram IO + points/account side effects."""
+
+ def __init__(self, bot: Any, db: DBManager):
+ self.bot = bot
+ self.db = db
+
+ @staticmethod
+ def display_name(user: Any) -> str:
+ return user.username or user.first_name or f"User_{user.id}"
+
+ def ensure_query_points(self, message: Any, cost: int, label: str) -> bool:
+ user = message.from_user
+ self.db.upsert_user(user.id, self.display_name(user))
+ result = self.db.spend_points(user.id, cost)
+ if result.get("ok"):
+ return True
+
+ balance = int(result.get("balance") or 0)
+ required = int(result.get("required") or cost)
+ missing = max(0, required - balance)
+ self.bot.reply_to(
+ message,
+ (
+ f"❌ 积分不足,无法执行 {label}\n"
+ f"当前积分: {balance}\n"
+ f"需要积分: {required}\n"
+ f"还差积分: {missing}\n\n"
+ f"积分规则:每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分,"
+ f"每日上限 {MESSAGE_DAILY_CAP} 分。"
+ ),
+ parse_mode="HTML",
+ )
+ return False
+
+ def build_welcome_text(self) -> str:
+ return (
+ "🚀 PolyWeather 天气查询机器人\n\n"
+ "可用指令:\n"
+ f"/city [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
+ f"/deb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
+ "/top - 查看积分排行榜\n"
+ "/id - 获取当前聊天的 Chat ID\n\n"
+ "/diag - 查看 Bot 启动诊断\n\n"
+ "示例: /city 伦敦\n"
+ f"💡 提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分,"
+ f"每日上限 {MESSAGE_DAILY_CAP} 分。"
+ )
+
+ def build_points_rank_text(self, user: Any) -> str:
+ self.db.upsert_user(user.id, self.display_name(user))
+ user_info = self.db.get_user(user.id)
+
+ leaderboard = self.db.get_leaderboard(limit=5)
+ rank_text = "🏆 PolyWeather 活跃度排行榜\n"
+ rank_text += "────────────────────\n"
+ for i, entry in enumerate(leaderboard):
+ medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
+ rank_text += f"{medal} {entry['username'][:12]}: {entry['points']} 点\n"
+
+ if user_info:
+ rank_text += "────────────────────\n"
+ rank_text += (
+ "👤 我的状态:\n"
+ f"┣ 积分: {user_info['points']}\n"
+ f"┣ 发言: {user_info['message_count']} 次\n"
+ f"┣ 今日发言积分: {user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}\n"
+ f"┗ /city 消耗: {CITY_QUERY_COST} | /deb 消耗: {DEB_QUERY_COST}"
+ )
+ return rank_text
+
+ def track_group_text_activity(self, message: Any) -> None:
+ text = str(getattr(message, "text", "") or "")
+ if text.startswith("/"):
+ return
+ chat = getattr(message, "chat", None)
+ if not chat or chat.type not in ("group", "supergroup"):
+ return
+
+ user = message.from_user
+ username = self.display_name(user)
+ self.db.upsert_user(user.id, username)
+
+ result = self.db.add_message_activity(
+ user.id,
+ text=text,
+ points_to_add=MESSAGE_POINTS,
+ cooldown_sec=MESSAGE_COOLDOWN_SEC,
+ daily_cap=MESSAGE_DAILY_CAP,
+ min_text_length=MESSAGE_MIN_LENGTH,
+ )
+ if result.get("awarded"):
+ logger.info(
+ f"message points awarded user={user.id} points=+{MESSAGE_POINTS} "
+ f"daily_points={result.get('daily_points')}/{MESSAGE_DAILY_CAP}"
+ )
diff --git a/src/bot/observability.py b/src/bot/observability.py
new file mode 100644
index 00000000..a8e277d3
--- /dev/null
+++ b/src/bot/observability.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from time import perf_counter
+from typing import Any
+
+from loguru import logger
+
+
+def _safe_attr(obj: Any, attr: str, default: Any = None) -> Any:
+ try:
+ return getattr(obj, attr, default)
+ except Exception:
+ return default
+
+
+class CommandTrace:
+ """Small helper for structured command logs."""
+
+ def __init__(self, command: str, message: Any):
+ self.command = command
+ self.user_id = _safe_attr(_safe_attr(message, "from_user"), "id", "unknown")
+ self.chat_id = _safe_attr(_safe_attr(message, "chat"), "id", "unknown")
+ self.start = perf_counter()
+ self.status = "ok"
+ self.note = ""
+
+ def set_status(self, status: str, note: str = "") -> None:
+ self.status = status
+ self.note = note
+
+ def emit(self) -> None:
+ elapsed_ms = (perf_counter() - self.start) * 1000.0
+ note_part = f" note={self.note}" if self.note else ""
+ logger.info(
+ "bot command command={} status={} user_id={} chat_id={} elapsed_ms={:.1f}{}",
+ self.command,
+ self.status,
+ self.user_id,
+ self.chat_id,
+ elapsed_ms,
+ note_part,
+ )
+
diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py
new file mode 100644
index 00000000..79bbae39
--- /dev/null
+++ b/src/bot/orchestrator.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+import os
+from typing import Any
+
+from loguru import logger # type: ignore
+
+from src.bot.analysis.city_analysis_service import CityAnalysisService
+from src.bot.analysis.deb_analysis_service import DebAnalysisService
+from src.bot.command_guard import CommandGuard
+from src.bot.handlers.activity import ActivityHandler
+from src.bot.handlers.basic import BasicCommandHandler
+from src.bot.handlers.city import CityCommandHandler
+from src.bot.handlers.deb import DebCommandHandler
+from src.bot.io_layer import BotIOLayer
+from src.bot.runtime_coordinator import StartupCoordinator
+from src.bot.services.city_command_service import CityCommandService
+from src.bot.services.deb_command_service import DebCommandService
+from src.bot.services.entitlement_service import BotEntitlementService
+
+
+def _project_root() -> str:
+ return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+
+def _register_handlers(
+ bot: Any,
+ io_layer: BotIOLayer,
+ guard: CommandGuard,
+ city_service: CityCommandService,
+ deb_service: DebCommandService,
+ startup_coordinator: StartupCoordinator,
+) -> None:
+ BasicCommandHandler(
+ bot=bot,
+ io_layer=io_layer,
+ runtime_status_provider=startup_coordinator.get_runtime_status,
+ ).register()
+ CityCommandHandler(bot=bot, guard=guard, city_service=city_service).register()
+ DebCommandHandler(bot=bot, guard=guard, deb_service=deb_service).register()
+ ActivityHandler(bot=bot, io_layer=io_layer).register()
+
+
+def start_bot() -> None:
+ import telebot # type: ignore
+
+ from src.data_collection.weather_sources import WeatherDataCollector
+ from src.database.db_manager import DBManager
+ from src.utils.config_loader import load_config
+
+ config = load_config()
+ token = os.getenv("TELEGRAM_BOT_TOKEN")
+ if not token:
+ logger.error("未找到 TELEGRAM_BOT_TOKEN 环境变量")
+ return
+
+ bot = telebot.TeleBot(token)
+ db = DBManager()
+ weather = WeatherDataCollector(config)
+
+ io_layer = BotIOLayer(bot=bot, db=db)
+ city_analysis = CityAnalysisService(weather=weather)
+ deb_analysis = DebAnalysisService(project_root=_project_root())
+ entitlement_service = BotEntitlementService(db=db)
+ guard = CommandGuard(io_layer=io_layer, entitlement_service=entitlement_service)
+ city_service = CityCommandService(analysis=city_analysis)
+ deb_service = DebCommandService(analysis=deb_analysis)
+ startup_coordinator = StartupCoordinator(
+ bot=bot,
+ config=config,
+ entitlement_enabled=entitlement_service.enabled,
+ protected_commands=sorted(entitlement_service.protected_commands),
+ )
+
+ _register_handlers(
+ bot=bot,
+ io_layer=io_layer,
+ guard=guard,
+ city_service=city_service,
+ deb_service=deb_service,
+ startup_coordinator=startup_coordinator,
+ )
+ runtime_status = startup_coordinator.start_all()
+ started_count = sum(1 for loop in runtime_status.loops if loop.started)
+
+ logger.info(
+ "🤖 Bot 启动中... entitlement_enabled={} protected_commands={} loops_started={}/{}",
+ entitlement_service.enabled,
+ ",".join(sorted(entitlement_service.protected_commands)),
+ started_count,
+ len(runtime_status.loops),
+ )
+ bot.infinity_polling()
diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py
new file mode 100644
index 00000000..27438ffb
--- /dev/null
+++ b/src/bot/runtime_coordinator.py
@@ -0,0 +1,236 @@
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass, field
+from datetime import datetime
+from importlib import import_module
+from typing import Any, Callable, Dict, List, Optional
+
+
+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) -> int:
+ raw = os.getenv(name)
+ if raw is None:
+ return default
+ try:
+ return int(raw)
+ except Exception:
+ return default
+
+
+def _parse_csv_count(raw: Optional[str]) -> int:
+ if not raw:
+ return 0
+ return len([part for part in str(raw).split(",") if str(part).strip()])
+
+
+@dataclass
+class LoopStatus:
+ key: str
+ label: str
+ configured_enabled: bool
+ started: bool
+ reason: str
+ details: Dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class RuntimeStatus:
+ started_at: str
+ loops: List[LoopStatus]
+ entitlement_enabled: bool
+ protected_commands: List[str]
+
+ def loop_map(self) -> Dict[str, LoopStatus]:
+ return {loop.key: loop for loop in self.loops}
+
+
+class StartupCoordinator:
+ """Centralized startup orchestration + diagnostics snapshot."""
+
+ def __init__(
+ self,
+ bot: Any,
+ config: Dict[str, Any],
+ entitlement_enabled: bool,
+ protected_commands: List[str],
+ ):
+ self.bot = bot
+ self.config = config
+ self.entitlement_enabled = entitlement_enabled
+ self.protected_commands = protected_commands
+ self._runtime_status = RuntimeStatus(
+ started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
+ loops=[],
+ entitlement_enabled=entitlement_enabled,
+ protected_commands=protected_commands,
+ )
+
+ def get_runtime_status(self) -> RuntimeStatus:
+ return self._runtime_status
+
+ def start_all(self) -> RuntimeStatus:
+ loops = [
+ self._start_trade_alert_loop(),
+ self._start_polygon_wallet_loop(),
+ self._start_polymarket_wallet_activity_loop(),
+ ]
+ self._runtime_status = RuntimeStatus(
+ started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
+ loops=loops,
+ entitlement_enabled=self.entitlement_enabled,
+ protected_commands=self.protected_commands,
+ )
+ return self._runtime_status
+
+ def _start_with_validation(
+ self,
+ key: str,
+ label: str,
+ configured_enabled: bool,
+ details: Dict[str, Any],
+ validation_error: Optional[str],
+ starter: Callable[[], Any],
+ ) -> LoopStatus:
+ if not configured_enabled:
+ return LoopStatus(
+ key=key,
+ label=label,
+ configured_enabled=False,
+ started=False,
+ reason="disabled_by_env",
+ details=details,
+ )
+ if validation_error:
+ return LoopStatus(
+ key=key,
+ label=label,
+ configured_enabled=True,
+ started=False,
+ reason=validation_error,
+ details=details,
+ )
+ thread = starter()
+ started = thread is not None
+ reason = "started" if started else "starter_returned_none"
+ if started:
+ details = {**details, "thread": getattr(thread, "name", "")}
+ return LoopStatus(
+ key=key,
+ label=label,
+ configured_enabled=True,
+ started=started,
+ reason=reason,
+ details=details,
+ )
+
+ def _start_trade_alert_loop(self) -> LoopStatus:
+ enabled = _env_bool("TELEGRAM_ALERT_PUSH_ENABLED", True)
+ chat_id = str(os.getenv("TELEGRAM_CHAT_ID") or "").strip()
+ mispricing_only = _env_bool("TELEGRAM_ALERT_MISPRICING_ONLY", True)
+ interval = (
+ max(300, _env_int("TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC", 7200))
+ if mispricing_only
+ else max(60, _env_int("TELEGRAM_ALERT_PUSH_INTERVAL_SEC", 300))
+ )
+ cities_count = _parse_csv_count(os.getenv("TELEGRAM_ALERT_CITIES"))
+ details = {
+ "mode": "mispricing-only" if mispricing_only else "full",
+ "interval_sec": interval,
+ "cities_count": cities_count,
+ }
+ validation_error = None if chat_id else "missing_TELEGRAM_CHAT_ID"
+ return self._start_with_validation(
+ key="trade_alert_push",
+ label="错价雷达推送",
+ configured_enabled=enabled,
+ details=details,
+ validation_error=validation_error,
+ starter=lambda: import_module("src.utils.telegram_push").start_trade_alert_push_loop(
+ self.bot,
+ self.config,
+ ),
+ )
+
+ def _start_polygon_wallet_loop(self) -> LoopStatus:
+ enabled = _env_bool("POLYGON_WALLET_WATCH_ENABLED", False)
+ chat_id = str(os.getenv("TELEGRAM_CHAT_ID") or "").strip()
+ rpc_url = str(os.getenv("POLYGON_RPC_URL") or "").strip()
+ wallets_count = _parse_csv_count(os.getenv("POLYGON_WALLET_WATCH_ADDRESSES"))
+ poll = max(3, _env_int("POLYGON_WALLET_WATCH_INTERVAL_SEC", 8))
+ details = {
+ "poll_sec": poll,
+ "wallets_count": wallets_count,
+ "polymarket_only": _env_bool("POLYGON_WALLET_WATCH_POLYMARKET_ONLY", True),
+ }
+ validation_error = None
+ if not chat_id:
+ validation_error = "missing_TELEGRAM_CHAT_ID"
+ elif not rpc_url:
+ validation_error = "missing_POLYGON_RPC_URL"
+ elif wallets_count == 0:
+ validation_error = "missing_POLYGON_WALLET_WATCH_ADDRESSES"
+ return self._start_with_validation(
+ key="polygon_wallet_watch",
+ label="Polygon 钱包监听",
+ configured_enabled=enabled,
+ details=details,
+ validation_error=validation_error,
+ starter=lambda: import_module(
+ "src.onchain.polygon_wallet_watcher"
+ ).start_polygon_wallet_watch_loop(self.bot),
+ )
+
+ def _start_polymarket_wallet_activity_loop(self) -> LoopStatus:
+ enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False)
+ chat_id = str(os.getenv("TELEGRAM_CHAT_ID") or "").strip()
+ users_count = _parse_csv_count(os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS"))
+ poll = max(5, _env_int("POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC", 20))
+ details = {
+ "poll_sec": poll,
+ "users_count": users_count,
+ "link_preview": _env_bool("POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW", True),
+ }
+ validation_error = None
+ if not chat_id:
+ validation_error = "missing_TELEGRAM_CHAT_ID"
+ elif users_count == 0:
+ validation_error = "missing_POLYMARKET_WALLET_ACTIVITY_USERS"
+ return self._start_with_validation(
+ key="polymarket_wallet_activity",
+ label="Polymarket 钱包异动监听",
+ configured_enabled=enabled,
+ details=details,
+ validation_error=validation_error,
+ starter=lambda: import_module(
+ "src.onchain.polymarket_wallet_activity_watcher"
+ ).start_polymarket_wallet_activity_loop(self.bot),
+ )
+
+
+def render_runtime_status_html(status: RuntimeStatus) -> str:
+ lines = [
+ "🧭 Bot 启动诊断",
+ f"启动时间: {status.started_at}",
+ "",
+ f"Entitlement: {'ON' if status.entitlement_enabled else 'OFF'}",
+ f"受保护命令: {', '.join(status.protected_commands) or '--'}",
+ "",
+ "后台循环:",
+ ]
+ for loop in status.loops:
+ icon = "✅" if loop.started else ("⏸" if not loop.configured_enabled else "⚠️")
+ detail_str = ", ".join(f"{k}={v}" for k, v in sorted(loop.details.items()))
+ lines.append(
+ f"{icon} {loop.label} | enabled={str(loop.configured_enabled).lower()} | "
+ f"started={str(loop.started).lower()} | reason={loop.reason}"
+ )
+ if detail_str:
+ lines.append(f" {detail_str}")
+ return "\n".join(lines)
diff --git a/src/bot/services/__init__.py b/src/bot/services/__init__.py
new file mode 100644
index 00000000..73224098
--- /dev/null
+++ b/src/bot/services/__init__.py
@@ -0,0 +1,2 @@
+"""Bot service layer."""
+
diff --git a/src/bot/services/city_command_service.py b/src/bot/services/city_command_service.py
new file mode 100644
index 00000000..878f6b7b
--- /dev/null
+++ b/src/bot/services/city_command_service.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import List, Optional
+
+from src.bot.analysis.city_analysis_service import CityAnalysisService
+
+
+@dataclass
+class CityResolveResult:
+ ok: bool
+ city_name: Optional[str] = None
+ supported_cities: List[str] | None = None
+
+
+@dataclass
+class CityReportResult:
+ ok: bool
+ report: Optional[str] = None
+ error: Optional[str] = None
+
+
+class CityCommandService:
+ def __init__(self, analysis: CityAnalysisService):
+ self.analysis = analysis
+
+ def resolve_city(self, city_input: str) -> CityResolveResult:
+ city_name, supported = self.analysis.resolve_city(city_input)
+ if not city_name:
+ return CityResolveResult(ok=False, supported_cities=supported)
+ return CityResolveResult(ok=True, city_name=city_name, supported_cities=supported)
+
+ def build_report(self, city_name: str, city_query_cost: int) -> CityReportResult:
+ try:
+ report = self.analysis.build_city_report(city_name, city_query_cost)
+ return CityReportResult(ok=True, report=report)
+ except Exception as exc:
+ return CityReportResult(ok=False, error=str(exc))
diff --git a/src/bot/services/deb_command_service.py b/src/bot/services/deb_command_service.py
new file mode 100644
index 00000000..8e9bb66d
--- /dev/null
+++ b/src/bot/services/deb_command_service.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Optional
+
+from src.bot.analysis.deb_analysis_service import DebAnalysisService
+
+
+@dataclass
+class DebReportResult:
+ ok: bool
+ report: Optional[str] = None
+ error: Optional[str] = None
+
+
+class DebCommandService:
+ def __init__(self, analysis: DebAnalysisService):
+ self.analysis = analysis
+
+ def resolve_city(self, city_input: str) -> str:
+ return self.analysis.resolve_deb_city(city_input)
+
+ def has_history(self, city_name: str) -> bool:
+ return self.analysis.has_deb_history(city_name)
+
+ def build_report(self, city_name: str, deb_query_cost: int) -> DebReportResult:
+ try:
+ report = self.analysis.build_deb_accuracy_report(city_name, deb_query_cost)
+ return DebReportResult(ok=True, report=report)
+ except Exception as exc:
+ return DebReportResult(ok=False, error=str(exc))
diff --git a/src/bot/services/entitlement_service.py b/src/bot/services/entitlement_service.py
new file mode 100644
index 00000000..49327b20
--- /dev/null
+++ b/src/bot/services/entitlement_service.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from typing import Iterable, Set
+
+from src.database.db_manager import DBManager
+
+
+def _env_bool(name: str, default: bool = False) -> bool:
+ raw = str(os.getenv(name, "")).strip().lower()
+ if not raw:
+ return default
+ return raw in {"1", "true", "yes", "on"}
+
+
+@dataclass
+class EntitlementDecision:
+ allowed: bool
+ reason: str
+
+
+class BotEntitlementService:
+ """
+ Payment/entitlement pre-hook for command access.
+
+ Disabled by default. Enable with:
+ POLYWEATHER_BOT_REQUIRE_ENTITLEMENT=true
+ """
+
+ def __init__(
+ self,
+ db: DBManager,
+ enabled: bool | None = None,
+ protected_commands: Iterable[str] | None = None,
+ ):
+ self.db = db
+ self.enabled = _env_bool("POLYWEATHER_BOT_REQUIRE_ENTITLEMENT", False) if enabled is None else enabled
+ commands = protected_commands or ("/city", "/deb")
+ self.protected_commands: Set[str] = {str(c).strip().lower() for c in commands if str(c).strip()}
+
+ def check(self, user_id: int, command_label: str) -> EntitlementDecision:
+ command = str(command_label or "").strip().lower()
+ if not self.enabled:
+ return EntitlementDecision(True, "entitlement_disabled")
+ if command not in self.protected_commands:
+ return EntitlementDecision(True, "command_not_protected")
+
+ user = self.db.get_user(user_id) or {}
+ has_premium = bool(user.get("is_web_premium") or user.get("is_group_premium"))
+ if has_premium:
+ return EntitlementDecision(True, "premium_user")
+ return EntitlementDecision(False, "premium_required")
+
diff --git a/src/bot/settings.py b/src/bot/settings.py
new file mode 100644
index 00000000..c5bf5f94
--- /dev/null
+++ b/src/bot/settings.py
@@ -0,0 +1,7 @@
+MESSAGE_POINTS = 4
+MESSAGE_DAILY_CAP = 50
+MESSAGE_MIN_LENGTH = 2
+MESSAGE_COOLDOWN_SEC = 30
+CITY_QUERY_COST = 1
+DEB_QUERY_COST = 1
+
diff --git a/tests/test_bot_basic_handler.py b/tests/test_bot_basic_handler.py
new file mode 100644
index 00000000..103ca42f
--- /dev/null
+++ b/tests/test_bot_basic_handler.py
@@ -0,0 +1,55 @@
+from types import SimpleNamespace
+
+from src.bot.handlers.basic import BasicCommandHandler
+from src.bot.runtime_coordinator import RuntimeStatus
+
+
+class DummyBot:
+ def __init__(self):
+ self.replies = []
+
+ def reply_to(self, message, text, parse_mode=None):
+ self.replies.append({"text": text, "parse_mode": parse_mode, "chat_id": message.chat.id})
+
+ def send_message(self, chat_id, text, parse_mode=None): # pragma: no cover
+ pass
+
+ def message_handler(self, *args, **kwargs): # pragma: no cover - decorator stub
+ def _decorator(func):
+ return func
+
+ return _decorator
+
+
+def _message(text: str):
+ return SimpleNamespace(
+ text=text,
+ from_user=SimpleNamespace(id=1, username="u", first_name="U"),
+ chat=SimpleNamespace(id=100),
+ )
+
+
+def test_basic_handler_diag_returns_html():
+ runtime = RuntimeStatus(
+ started_at="2026-03-12 00:00:00 UTC",
+ loops=[],
+ entitlement_enabled=False,
+ protected_commands=["/city", "/deb"],
+ )
+ bot = DummyBot()
+ io_layer = SimpleNamespace(
+ build_welcome_text=lambda: "WELCOME",
+ build_points_rank_text=lambda _user: "TOP",
+ )
+ handler = BasicCommandHandler(
+ bot=bot,
+ io_layer=io_layer,
+ runtime_status_provider=lambda: runtime,
+ )
+
+ handler.handle_diag(_message("/diag"))
+
+ assert len(bot.replies) == 1
+ assert bot.replies[0]["parse_mode"] == "HTML"
+ assert "Bot 启动诊断" in bot.replies[0]["text"]
+
diff --git a/tests/test_bot_command_guard.py b/tests/test_bot_command_guard.py
new file mode 100644
index 00000000..537ca206
--- /dev/null
+++ b/tests/test_bot_command_guard.py
@@ -0,0 +1,50 @@
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+from src.bot.command_guard import CommandGuard
+from src.bot.services.entitlement_service import BotEntitlementService
+
+
+class _FakeDB:
+ def __init__(self, user):
+ self._user = user
+
+ def get_user(self, _user_id):
+ return self._user
+
+
+def _message():
+ return SimpleNamespace(
+ from_user=SimpleNamespace(id=123, username="tester", first_name="Tester"),
+ chat=SimpleNamespace(id=999),
+ )
+
+
+def test_guard_blocks_non_premium_when_entitlement_enabled():
+ fake_bot = SimpleNamespace(reply_to=Mock())
+ io_layer = SimpleNamespace(bot=fake_bot, ensure_query_points=Mock(return_value=True))
+ entitlement = BotEntitlementService(db=_FakeDB(user=None), enabled=True)
+ guard = CommandGuard(io_layer=io_layer, entitlement_service=entitlement)
+
+ ok = guard.ensure_access_and_points(_message(), 1, "/city")
+
+ assert ok is False
+ assert io_layer.ensure_query_points.call_count == 0
+ assert fake_bot.reply_to.call_count == 1
+
+
+def test_guard_allows_premium_then_charges_points():
+ fake_bot = SimpleNamespace(reply_to=Mock())
+ io_layer = SimpleNamespace(bot=fake_bot, ensure_query_points=Mock(return_value=True))
+ entitlement = BotEntitlementService(
+ db=_FakeDB(user={"is_web_premium": 1, "is_group_premium": 0}),
+ enabled=True,
+ )
+ guard = CommandGuard(io_layer=io_layer, entitlement_service=entitlement)
+
+ ok = guard.ensure_access_and_points(_message(), 1, "/city")
+
+ assert ok is True
+ assert io_layer.ensure_query_points.call_count == 1
+ assert fake_bot.reply_to.call_count == 0
+
diff --git a/tests/test_bot_handlers.py b/tests/test_bot_handlers.py
new file mode 100644
index 00000000..34a7d235
--- /dev/null
+++ b/tests/test_bot_handlers.py
@@ -0,0 +1,98 @@
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+from src.bot.handlers.city import CityCommandHandler
+from src.bot.handlers.deb import DebCommandHandler
+from src.bot.services.city_command_service import CityReportResult, CityResolveResult
+from src.bot.services.deb_command_service import DebReportResult
+
+
+class DummyBot:
+ def __init__(self):
+ self.replies = []
+ self.sent = []
+
+ def reply_to(self, message, text, parse_mode=None):
+ self.replies.append(
+ {
+ "chat_id": message.chat.id,
+ "text": text,
+ "parse_mode": parse_mode,
+ }
+ )
+
+ def send_message(self, chat_id, text, parse_mode=None):
+ self.sent.append(
+ {
+ "chat_id": chat_id,
+ "text": text,
+ "parse_mode": parse_mode,
+ }
+ )
+
+ def message_handler(self, *args, **kwargs): # pragma: no cover - decorator stub
+ def _decorator(func):
+ return func
+
+ return _decorator
+
+
+def _message(text: str):
+ return SimpleNamespace(
+ text=text,
+ from_user=SimpleNamespace(id=123, username="tester", first_name="Tester"),
+ chat=SimpleNamespace(id=999),
+ )
+
+
+def test_city_handler_missing_city_shows_usage():
+ bot = DummyBot()
+ guard = SimpleNamespace(ensure_access_and_points=Mock(return_value=True))
+ city_service = SimpleNamespace(
+ resolve_city=Mock(),
+ build_report=Mock(),
+ )
+ handler = CityCommandHandler(bot=bot, guard=guard, city_service=city_service)
+
+ handler.handle(_message("/city"))
+
+ assert len(bot.replies) == 1
+ assert "请输入城市名称" in bot.replies[0]["text"]
+ assert guard.ensure_access_and_points.call_count == 0
+
+
+def test_city_handler_happy_path_pushes_progress_and_report():
+ bot = DummyBot()
+ guard = SimpleNamespace(ensure_access_and_points=Mock(return_value=True))
+ city_service = SimpleNamespace(
+ resolve_city=Mock(
+ return_value=CityResolveResult(ok=True, city_name="london", supported_cities=["london"])
+ ),
+ build_report=Mock(return_value=CityReportResult(ok=True, report="CITY_REPORT")),
+ )
+ handler = CityCommandHandler(bot=bot, guard=guard, city_service=city_service)
+
+ handler.handle(_message("/city london"))
+
+ assert len(bot.sent) == 2
+ assert "正在查询 London 的天气数据" in bot.sent[0]["text"]
+ assert bot.sent[1]["text"] == "CITY_REPORT"
+ assert bot.sent[1]["parse_mode"] == "HTML"
+
+
+def test_deb_handler_history_missing_returns_hint():
+ bot = DummyBot()
+ guard = SimpleNamespace(ensure_access_and_points=Mock(return_value=True))
+ deb_service = SimpleNamespace(
+ resolve_city=Mock(return_value="ankara"),
+ has_history=Mock(return_value=False),
+ build_report=Mock(return_value=DebReportResult(ok=True, report="DEB_REPORT")),
+ )
+ handler = DebCommandHandler(bot=bot, guard=guard, deb_service=deb_service)
+
+ handler.handle(_message("/deb ankara"))
+
+ assert len(bot.replies) == 1
+ assert "暂无 ankara 的历史数据" in bot.replies[0]["text"]
+ assert guard.ensure_access_and_points.call_count == 0
+
diff --git a/tests/test_bot_runtime_coordinator.py b/tests/test_bot_runtime_coordinator.py
new file mode 100644
index 00000000..2f217c49
--- /dev/null
+++ b/tests/test_bot_runtime_coordinator.py
@@ -0,0 +1,42 @@
+from src.bot.runtime_coordinator import RuntimeStatus, StartupCoordinator, render_runtime_status_html
+
+
+class DummyBot:
+ pass
+
+
+def test_startup_coordinator_respects_disable_flags(monkeypatch):
+ monkeypatch.setenv("TELEGRAM_ALERT_PUSH_ENABLED", "false")
+ monkeypatch.setenv("POLYGON_WALLET_WATCH_ENABLED", "false")
+ monkeypatch.setenv("POLYMARKET_WALLET_ACTIVITY_ENABLED", "false")
+ monkeypatch.delenv("TELEGRAM_CHAT_ID", raising=False)
+
+ coordinator = StartupCoordinator(
+ bot=DummyBot(),
+ config={},
+ entitlement_enabled=False,
+ protected_commands=["/city", "/deb"],
+ )
+ runtime = coordinator.start_all()
+ loop_map = runtime.loop_map()
+
+ assert loop_map["trade_alert_push"].configured_enabled is False
+ assert loop_map["trade_alert_push"].started is False
+ assert loop_map["trade_alert_push"].reason == "disabled_by_env"
+ assert loop_map["polygon_wallet_watch"].reason == "disabled_by_env"
+ assert loop_map["polymarket_wallet_activity"].reason == "disabled_by_env"
+
+
+def test_render_runtime_status_html_contains_key_fields():
+ runtime = RuntimeStatus(
+ started_at="2026-03-12 00:00:00 UTC",
+ entitlement_enabled=True,
+ protected_commands=["/city", "/deb"],
+ loops=[],
+ )
+ html = render_runtime_status_html(runtime)
+
+ assert "Bot 启动诊断" in html
+ assert "Entitlement" in html
+ assert "/city, /deb" in html
+