feat: Implement new bot architecture including handlers, services, analysis modules, and comprehensive tests.

This commit is contained in:
2569718930@qq.com
2026-03-12 11:44:52 +08:00
parent c582015163
commit f4fea03f35
28 changed files with 1525 additions and 445 deletions
+2
View File
@@ -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=
+2 -405
View File
@@ -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"❌ 积分不足,无法执行 <b>{label}</b>\n"
f"当前积分: <code>{balance}</code>\n"
f"需要积分: <code>{required}</code>\n"
f"还差积分: <code>{missing}</code>\n\n"
f"积分规则:每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
),
parse_mode="HTML",
)
return False
@bot.message_handler(commands=["start", "help"])
def send_welcome(message):
welcome_text = (
"🚀 <b>PolyWeather 天气查询机器人</b>\n\n"
"可用指令:\n"
f"/city [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
f"/deb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
"/top - 查看积分排行榜\n"
"/id - 获取当前聊天的 Chat ID\n\n"
"示例: <code>/city 伦敦</code>\n"
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
)
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 是: <code>{message.chat.id}</code>",
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 = "🏆 <b>PolyWeather 活跃度排行榜</b>\n"
rank_text += "────────────────────\n"
for i, entry in enumerate(leaderboard):
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
rank_text += f"{medal} {entry['username'][:12]}: <b>{entry['points']}</b> 点\n"
if user_info:
rank_text += "────────────────────\n"
rank_text += (
f"👤 <b>我的状态:</b>\n"
f"┣ 积分: <code>{user_info['points']}</code>\n"
f"┣ 发言: <code>{user_info['message_count']}</code> 次\n"
f"┣ 今日发言积分: <code>{user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}</code>\n"
f"┗ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
)
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,
"❌ 用法: <code>/deb ankara</code>",
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"📊 <b>DEB 准确率报告 - {city_name.title()}</b>",
"",
"📅 <b>近日记录:</b>",
]
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"🏁 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
)
if model_errors:
lines.append("")
lines.append("📈 <b>模型 MAE 对比:</b>")
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" <b>DEB融合: {deb_mae:.1f}°</b>")
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("🔍 <b>偏差分析:</b>")
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("💡 <b>建议:</b>")
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"💸 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
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用法: <code>/city chicago</code>",
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"❌ 未找到城市: <b>{city_input}</b>\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()
+40 -1
View File
@@ -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,
+37 -39
View File
@@ -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"🎲 <b>结算预测</b>:已锁定 {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"🎲 <b>结算概率</b> (μ={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"🎲 <b>结算预测</b>:已锁定 {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:
+2
View File
@@ -0,0 +1,2 @@
"""Telegram bot composition layers."""
+2
View File
@@ -0,0 +1,2 @@
"""Bot analysis services."""
+36
View File
@@ -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,
)
+220
View File
@@ -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"📊 <b>DEB 准确率报告 - {city_name.title()}</b>",
"",
"📅 <b>近日记录:</b>",
]
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"🏁 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
)
if model_errors:
lines.append("")
lines.append("📈 <b>模型 MAE 对比:</b>")
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" <b>DEB融合: {deb_mae:.1f}°</b>")
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("🔍 <b>偏差分析:</b>")
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("💡 <b>建议:</b>")
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"💸 本次消耗 <code>{deb_query_cost}</code> 积分。")
return "\n".join(lines)
+31
View File
@@ -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)
+47
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
"""Bot command handlers."""
+20
View File
@@ -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)
+75
View File
@@ -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 是: <code>{message.chat.id}</code>",
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()
+76
View File
@@ -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用法: <code>/city chicago</code>",
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"❌ 未找到城市: <b>{city_input}</b>\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()
+71
View File
@@ -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,
"❌ 用法: <code>/deb ankara</code>",
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()
+113
View File
@@ -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"❌ 积分不足,无法执行 <b>{label}</b>\n"
f"当前积分: <code>{balance}</code>\n"
f"需要积分: <code>{required}</code>\n"
f"还差积分: <code>{missing}</code>\n\n"
f"积分规则:每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
),
parse_mode="HTML",
)
return False
def build_welcome_text(self) -> str:
return (
"🚀 <b>PolyWeather 天气查询机器人</b>\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"
"示例: <code>/city 伦敦</code>\n"
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
)
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 = "🏆 <b>PolyWeather 活跃度排行榜</b>\n"
rank_text += "────────────────────\n"
for i, entry in enumerate(leaderboard):
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
rank_text += f"{medal} {entry['username'][:12]}: <b>{entry['points']}</b> 点\n"
if user_info:
rank_text += "────────────────────\n"
rank_text += (
"👤 <b>我的状态:</b>\n"
f"┣ 积分: <code>{user_info['points']}</code>\n"
f"┣ 发言: <code>{user_info['message_count']}</code> 次\n"
f"┣ 今日发言积分: <code>{user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}</code>\n"
f"┗ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
)
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}"
)
+43
View File
@@ -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,
)
+93
View File
@@ -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()
+236
View File
@@ -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 = [
"🧭 <b>Bot 启动诊断</b>",
f"启动时间: <code>{status.started_at}</code>",
"",
f"Entitlement: <code>{'ON' if status.entitlement_enabled else 'OFF'}</code>",
f"受保护命令: <code>{', '.join(status.protected_commands) or '--'}</code>",
"",
"后台循环:",
]
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} <b>{loop.label}</b> | enabled={str(loop.configured_enabled).lower()} | "
f"started={str(loop.started).lower()} | reason=<code>{loop.reason}</code>"
)
if detail_str:
lines.append(f" <code>{detail_str}</code>")
return "\n".join(lines)
+2
View File
@@ -0,0 +1,2 @@
"""Bot service layer."""
+38
View File
@@ -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))
+31
View File
@@ -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))
+54
View File
@@ -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")
+7
View File
@@ -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
+55
View File
@@ -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"]
+50
View File
@@ -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
+98
View File
@@ -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
+42
View File
@@ -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