feat: implement PolyWeather Telegram bot with weather and DEB analysis, a point system, and configuration.

This commit is contained in:
2569718930@qq.com
2026-03-14 11:39:30 +08:00
parent 64f8b37014
commit 4bd531bbc9
9 changed files with 151 additions and 19 deletions
+18 -8
View File
@@ -5,6 +5,7 @@ from typing import Any
from loguru import logger
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_QUERY_COST
@@ -16,10 +17,12 @@ class CityCommandHandler:
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"])
@@ -32,7 +35,7 @@ class CityCommandHandler:
parts = (message.text or "").split(maxsplit=1)
if len(parts) < 2:
trace.set_status("bad_request", "missing_city")
self.bot.reply_to(
self.io_layer.send_query_message(
message,
"❌ 请输入城市名称\n\n用法: <code>/city chicago</code>",
parse_mode="HTML",
@@ -44,7 +47,7 @@ class CityCommandHandler:
if not resolved.ok:
city_list = ", ".join(resolved.supported_cities or [])
trace.set_status("bad_request", "city_not_supported")
self.bot.reply_to(
self.io_layer.send_query_message(
message,
f"❌ 未找到城市: <b>{city_input}</b>\n\n支持的城市: {city_list}",
parse_mode="HTML",
@@ -56,21 +59,28 @@ class CityCommandHandler:
trace.set_status("blocked", "guard_rejected")
return
self.bot.send_message(
message.chat.id, f"🔍 正在查询 {city_name.title()} 的天气数据..."
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.bot.reply_to(message, f"❌ 查询失败: {report_result.error}")
self.io_layer.send_query_message(
message,
f"❌ 查询失败: {report_result.error}",
)
return
self.bot.send_message(message.chat.id, str(report_result.report), parse_mode="HTML")
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.bot.reply_to(message, f"❌ 查询失败: {exc}")
self.io_layer.send_query_message(message, f"❌ 查询失败: {exc}")
finally:
trace.emit()
+15 -6
View File
@@ -5,6 +5,7 @@ from typing import Any
from loguru import logger
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_QUERY_COST
@@ -16,10 +17,12 @@ class DebCommandHandler:
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"])
@@ -32,7 +35,7 @@ class DebCommandHandler:
parts = (message.text or "").split(maxsplit=1)
if len(parts) < 2:
trace.set_status("bad_request", "missing_city")
self.bot.reply_to(
self.io_layer.send_query_message(
message,
"❌ 用法: <code>/deb ankara</code>",
parse_mode="HTML",
@@ -43,7 +46,7 @@ class DebCommandHandler:
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(
self.io_layer.send_query_message(
message,
f"❌ 暂无 {city_name} 的历史数据。",
parse_mode="HTML",
@@ -57,15 +60,21 @@ class DebCommandHandler:
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}")
self.io_layer.send_query_message(
message,
f"❌ 查询失败: {report_result.error}",
)
return
self.bot.reply_to(message, str(report_result.report), parse_mode="HTML")
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.bot.reply_to(message, f"❌ 查询失败: {exc}")
self.io_layer.send_query_message(message, f"❌ 查询失败: {exc}")
finally:
trace.emit()
+53 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import os
from datetime import datetime
from typing import Any
@@ -22,11 +23,62 @@ class BotIOLayer:
def __init__(self, bot: Any, db: DBManager):
self.bot = bot
self.db = db
self.query_topic_chat_id = str(
os.getenv("TELEGRAM_QUERY_TOPIC_CHAT_ID") or ""
).strip()
self.query_topic_id = self._safe_int(
os.getenv("TELEGRAM_QUERY_TOPIC_ID"),
default=0,
)
@staticmethod
def display_name(user: Any) -> str:
return user.username or user.first_name or f"User_{user.id}"
@staticmethod
def _safe_int(raw: Any, default: int = 0) -> int:
try:
return int(raw)
except Exception:
return default
def send_query_message(
self,
message: Any,
text: str,
*,
parse_mode: str | None = None,
) -> None:
chat = getattr(message, "chat", None)
fallback_chat_id = getattr(chat, "id", None)
target_chat_id = self.query_topic_chat_id or fallback_chat_id
if target_chat_id is None:
self.bot.send_message(message.chat.id, text, parse_mode=parse_mode)
return
kwargs = {}
if parse_mode:
kwargs["parse_mode"] = parse_mode
if self.query_topic_id > 0:
kwargs["message_thread_id"] = self.query_topic_id
try:
self.bot.send_message(target_chat_id, text, **kwargs)
return
except Exception as exc:
logger.warning(
"query topic send failed chat_id={} topic_id={} error={}",
target_chat_id,
self.query_topic_id,
exc,
)
# Fallback: drop topic and send to source chat.
if fallback_chat_id is not None:
fallback_kwargs = dict(kwargs)
fallback_kwargs.pop("message_thread_id", None)
self.bot.send_message(fallback_chat_id, text, **fallback_kwargs)
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))
@@ -37,7 +89,7 @@ class BotIOLayer:
balance = int(result.get("balance") or 0)
required = int(result.get("required") or cost)
missing = max(0, required - balance)
self.bot.reply_to(
self.send_query_message(
message,
(
f"❌ 积分不足,无法执行 <b>{label}</b>\n"
+12 -2
View File
@@ -36,8 +36,18 @@ def _register_handlers(
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()
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()