fix chart refresh and bot command surface

This commit is contained in:
2569718930@qq.com
2026-06-13 01:34:09 +08:00
parent cf35e55da4
commit f115be1fd3
26 changed files with 328 additions and 532 deletions
-31
View File
@@ -17,7 +17,6 @@ from src.auth.telegram_group_pricing import TelegramGroupPricing, TELEGRAM_MEMBE
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
_BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind"}
_BASIC_COMMANDS = {"start", "help", "id", "top", "diag", "bind", "unbind", "markets"}
class BasicCommandHandler:
@@ -60,10 +59,6 @@ class BasicCommandHandler:
def _unbind(message):
self._dispatch(message)
@self.bot.message_handler(commands=["markets"])
def _markets(message):
self._dispatch(message)
if hasattr(self.bot, "chat_join_request_handler"):
@self.bot.chat_join_request_handler(func=lambda request: True)
def _chat_join_request(request):
@@ -123,9 +118,6 @@ class BasicCommandHandler:
if command == "unbind":
self.handle_unbind(message)
return
if command == "markets":
self.handle_markets(message)
return
def handle_start_help(self, message: Any) -> None:
trace = CommandTrace("/start", message)
@@ -141,7 +133,6 @@ class BasicCommandHandler:
trace.set_status("ok")
finally:
trace.emit()
@staticmethod
def _is_private_text_fallback(message: Any) -> bool:
text = str(getattr(message, "text", "") or "").strip()
@@ -553,25 +544,3 @@ class BasicCommandHandler:
trace.set_status("ok", "unbound")
finally:
trace.emit()
def handle_markets(self, message: Any) -> None:
trace = CommandTrace("/markets", message)
try:
chat_type = str(getattr(getattr(message, "chat", None), "type", "") or "").strip().lower()
if chat_type and chat_type != "private":
self.bot.reply_to(
message,
"️ `/markets` 仅支持私聊机器人查询。",
parse_mode="Markdown",
)
trace.set_status("blocked", f"unsupported_chat_type:{chat_type}")
return
self.bot.reply_to(
message,
"️ 市场概览 (Focus Digest) 功能已移除。\n频道继续接收关键市场警报推送;如需查看当前市场状态,请访问 https://polyweather.top/",
disable_web_page_preview=True,
)
trace.set_status("ok", "removed")
finally:
trace.emit()
-111
View File
@@ -1,111 +0,0 @@
from __future__ import annotations
from typing import Any
from loguru import logger
from src.bot.command_parser import extract_command_name
from src.bot.command_parser import split_command_and_args
from src.bot.command_guard import CommandGuard
from src.bot.io_layer import BotIOLayer
from src.bot.observability import CommandTrace
from src.bot.services.city_command_service import CityCommandService
from src.bot.settings import CITY_DAILY_FREE_LIMIT, CITY_QUERY_COST
def _is_city_command(message: Any) -> bool:
command = extract_command_name(
getattr(message, "text", None),
getattr(message, "entities", None),
)
return command in {"city", "pwcity"}
class CityCommandHandler:
def __init__(
self,
bot: Any,
guard: CommandGuard,
city_service: CityCommandService,
io_layer: BotIOLayer,
):
self.bot = bot
self.guard = guard
self.city_service = city_service
self.io_layer = io_layer
def register(self) -> None:
@self.bot.message_handler(commands=["city", "pwcity"])
def _city_command(message):
self.handle(message)
@self.bot.message_handler(
func=lambda message: _is_city_command(message),
content_types=["text"],
)
def _city_text(message):
self.handle(message)
def handle(self, message: Any) -> None:
if getattr(message, "_pw_city_handled", False):
return
if not _is_city_command(message):
return
setattr(message, "_pw_city_handled", True)
setattr(message, "_pw_command_handled", True)
trace = CommandTrace("/city", message)
try:
_, args = split_command_and_args(getattr(message, "text", None))
if not args:
trace.set_status("bad_request", "missing_city")
self.io_layer.send_query_message(
message,
"❌ 请输入城市名称\n\n用法: <code>/city chicago</code>",
parse_mode="HTML",
)
return
city_input = args.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.io_layer.send_query_message(
message,
f"❌ 未找到城市: <b>{city_input}</b>\n\n支持的城市: {city_list}",
parse_mode="HTML",
)
return
city_name = str(resolved.city_name)
if not self.guard.check_daily_query_limit(message, "city", CITY_DAILY_FREE_LIMIT):
trace.set_status("blocked", "daily_query_limit")
return
if not self.guard.ensure_access_and_points(message, CITY_QUERY_COST, "/city"):
trace.set_status("blocked", "guard_rejected")
return
self.io_layer.send_query_message(
message,
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.io_layer.send_query_message(
message,
f"❌ 查询失败: {report_result.error}",
)
return
self.io_layer.send_query_message(
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("查询 /city 失败")
self.io_layer.send_query_message(message, f"❌ 查询失败: {exc}")
finally:
trace.emit()
-105
View File
@@ -1,105 +0,0 @@
from __future__ import annotations
from typing import Any
from loguru import logger
from src.bot.command_parser import extract_command_name
from src.bot.command_parser import split_command_and_args
from src.bot.command_guard import CommandGuard
from src.bot.io_layer import BotIOLayer
from src.bot.observability import CommandTrace
from src.bot.services.deb_command_service import DebCommandService
from src.bot.settings import DEB_DAILY_FREE_LIMIT, DEB_QUERY_COST
def _is_deb_command(message: Any) -> bool:
command = extract_command_name(
getattr(message, "text", None),
getattr(message, "entities", None),
)
return command in {"deb", "pwdeb"}
class DebCommandHandler:
def __init__(
self,
bot: Any,
guard: CommandGuard,
deb_service: DebCommandService,
io_layer: BotIOLayer,
):
self.bot = bot
self.guard = guard
self.deb_service = deb_service
self.io_layer = io_layer
def register(self) -> None:
@self.bot.message_handler(commands=["deb", "pwdeb"])
def _deb_command(message):
self.handle(message)
@self.bot.message_handler(
func=lambda message: _is_deb_command(message),
content_types=["text"],
)
def _deb_text(message):
self.handle(message)
def handle(self, message: Any) -> None:
if getattr(message, "_pw_deb_handled", False):
return
if not _is_deb_command(message):
return
setattr(message, "_pw_deb_handled", True)
setattr(message, "_pw_command_handled", True)
trace = CommandTrace("/deb", message)
try:
_, args = split_command_and_args(getattr(message, "text", None))
if not args:
trace.set_status("bad_request", "missing_city")
self.io_layer.send_query_message(
message,
"❌ 用法: <code>/deb ankara</code>",
parse_mode="HTML",
)
return
city_input = args.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.io_layer.send_query_message(
message,
f"❌ 暂无 {city_name} 的历史数据。",
parse_mode="HTML",
)
return
if not self.guard.check_daily_query_limit(message, "deb", DEB_DAILY_FREE_LIMIT):
trace.set_status("blocked", "daily_query_limit")
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.io_layer.send_query_message(
message,
f"❌ 查询失败: {report_result.error}",
)
return
self.io_layer.send_query_message(
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.io_layer.send_query_message(message, f"❌ 查询失败: {exc}")
finally:
trace.emit()
+3 -19
View File
@@ -1,14 +1,11 @@
from __future__ import annotations
import os
from datetime import datetime
from typing import Any, Dict, Optional, Tuple
from loguru import logger
from src.bot.settings import (
CITY_DAILY_FREE_LIMIT,
DEB_DAILY_FREE_LIMIT,
GROUP_MESSAGE_POINTS_ENABLED,
MESSAGE_COOLDOWN_SEC,
MESSAGE_DAILY_CAP,
@@ -194,28 +191,20 @@ class BotIOLayer:
def build_welcome_text(self) -> str:
return (
"🚀 <b>PolyWeather 天气查询机器人</b>\n\n"
"🚀 <b>PolyWeather 机器人</b>\n\n"
"可用指令:\n"
f"/city [城市名] 或 /pwcity [城市名] - 查询城市天气预测与实测 (免费, 每日 {CITY_DAILY_FREE_LIMIT} 次)\n"
f"/deb [城市名] 或 /pwdeb [城市名] - 查看 DEB 融合预测准确率 (免费, 每日 {DEB_DAILY_FREE_LIMIT} 次)\n"
"/markets - 私聊机器人查看当前市场监控摘要\n"
"/top - 查看积分排行榜\n"
"/id - 获取当前聊天的 Chat ID\n\n"
"/diag - 查看 Bot 启动诊断\n\n"
"/bind - 绑定 Supabase 账号(可选)\n"
"/unbind - 解除当前 Telegram 与网页账号绑定\n\n"
"🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n"
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>\n\n"
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
"💡 <i>提示: 积分现在通过邀请制度获得;有效邀请完成首次 Pro 付款后,邀请人获得积分奖励。</i>"
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>"
)
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)
now = datetime.now()
today_str = now.strftime("%Y-%m-%d")
leaderboard = self.db.get_leaderboard(limit=5)
rank_text = "🏆 <b>PolyWeather 用户积分排行</b>\n"
@@ -227,17 +216,12 @@ class BotIOLayer:
rank_text += f"{medal} {username}: <b>{points}</b> 分\n"
if user_info:
daily_queries_date = str(user_info.get("daily_queries_date") or "")
city_used = int(user_info.get("daily_city_queries") or 0) if daily_queries_date == today_str else 0
deb_used = int(user_info.get("daily_deb_queries") or 0) if daily_queries_date == today_str else 0
rank_text += "────────────────────\n"
rank_text += (
"👤 <b>我的状态:</b>\n"
f"┣ 累计积分: <code>{user_info['points']}</code>\n"
"┣ 积分获取: <code>邀请付费用户</code>\n"
" 抵扣规则: <code>500分 = 1 USDC,单笔最多抵3U</code>\n"
f"┗ /city 免费 ({city_used}/{CITY_DAILY_FREE_LIMIT}) | /deb 免费 ({deb_used}/{DEB_DAILY_FREE_LIMIT})"
" 抵扣规则: <code>500分 = 1 USDC,单笔最多抵3U</code>"
)
return rank_text
+6 -36
View File
@@ -5,32 +5,21 @@ 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.utils.config_validation import validate_or_raise
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _register_handlers(
bot: Any,
config: dict[str, Any],
io_layer: BotIOLayer,
guard: CommandGuard,
city_service: CityCommandService,
deb_service: DebCommandService,
guard: Any | None,
city_service: Any | None,
deb_service: Any | None,
startup_coordinator: StartupCoordinator,
) -> None:
BasicCommandHandler(
@@ -39,25 +28,12 @@ def _register_handlers(
runtime_status_provider=startup_coordinator.get_runtime_status,
config=config,
).register()
CityCommandHandler(
bot=bot,
guard=guard,
city_service=city_service,
io_layer=io_layer,
).register()
DebCommandHandler(
bot=bot,
guard=guard,
deb_service=deb_service,
io_layer=io_layer,
).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
@@ -70,14 +46,8 @@ def start_bot() -> None:
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())
guard = CommandGuard(io_layer=io_layer)
city_service = CityCommandService(analysis=city_analysis)
deb_service = DebCommandService(analysis=deb_analysis)
startup_coordinator = StartupCoordinator(
bot=bot,
config=config,
@@ -90,9 +60,9 @@ def start_bot() -> None:
bot=bot,
config=config,
io_layer=io_layer,
guard=guard,
city_service=city_service,
deb_service=deb_service,
guard=None,
city_service=None,
deb_service=None,
startup_coordinator=startup_coordinator,
)
runtime_status = startup_coordinator.start_all()
-38
View File
@@ -1,38 +0,0 @@
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
@@ -1,31 +0,0 @@
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_city(city_input)
def has_history(self, city_name: str) -> bool:
return self.analysis.has_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))
+1 -1
View File
@@ -41,7 +41,7 @@ class BotEntitlementService:
"POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT",
SUPABASE_ENTITLEMENT.enabled,
)
commands = protected_commands or ("/city", "/deb")
commands = protected_commands or ()
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:
-4
View File
@@ -31,10 +31,6 @@ MESSAGE_MIN_LENGTH = _env_int("POLYWEATHER_BOT_MESSAGE_MIN_LENGTH", 3, min_value
MESSAGE_COOLDOWN_SEC = _env_int("POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC", 30, min_value=0)
# Optional per-chat override map, parsed in BotIOLayer:
# POLYWEATHER_BOT_MESSAGE_COOLDOWN_BY_CHAT="-1003586303099:10,-1003539418691:20"
CITY_QUERY_COST = _env_int("POLYWEATHER_BOT_CITY_QUERY_COST", 0, min_value=0)
DEB_QUERY_COST = _env_int("POLYWEATHER_BOT_DEB_QUERY_COST", 0, min_value=0)
CITY_DAILY_FREE_LIMIT = _env_int("POLYWEATHER_BOT_CITY_DAILY_FREE_LIMIT", 10, min_value=1)
DEB_DAILY_FREE_LIMIT = _env_int("POLYWEATHER_BOT_DEB_DAILY_FREE_LIMIT", 10, min_value=1)
FIRST_MESSAGE_BONUS = _env_int("POLYWEATHER_BOT_FIRST_MESSAGE_BONUS", 2, min_value=0)
WELCOME_BONUS = _env_int("POLYWEATHER_BOT_WELCOME_BONUS", 20, min_value=0)