feat: Implement new bot architecture including handlers, services, analysis modules, and comprehensive tests.
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Telegram bot composition layers."""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Bot analysis services."""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Bot command handlers."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Bot service layer."""
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user