From f7b649bb0a8775620d2c7772ef6ef7aa11c3ba58 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 13 Mar 2026 05:53:41 +0800 Subject: [PATCH] feat: Implement bot orchestration, command handling, and runtime coordination for background services. --- .env.example | 7 +- src/bot/command_guard.py | 78 ++++++++++++--------- src/bot/io_layer.py | 26 +++++-- src/bot/orchestrator.py | 13 ++-- src/bot/runtime_coordinator.py | 18 +++-- src/bot/settings.py | 5 +- src/database/db_manager.py | 123 +++++++++++++++++++++++++++++++-- 7 files changed, 206 insertions(+), 64 deletions(-) diff --git a/.env.example b/.env.example index e46b957a..7bcaf2c5 100644 --- a/.env.example +++ b/.env.example @@ -38,9 +38,10 @@ SUPABASE_SERVICE_ROLE_KEY= SUPABASE_HTTP_TIMEOUT_SEC=8 SUPABASE_AUTH_CACHE_TTL_SEC=30 SUPABASE_SUB_CACHE_TTL_SEC=60 -# Bot command entitlement guard (/city, /deb). Off by default until payment API is wired. -POLYWEATHER_BOT_REQUIRE_ENTITLEMENT=false -POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=false +# Bot command access guard (/city, /deb): +# - Pro entitlement removed +# - user only needs to be a member of TELEGRAM_CHAT_ID group +POLYWEATHER_BOT_GROUP_INVITE_URL= # Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*) POLYWEATHER_REQUIRE_ENTITLEMENT=false POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= diff --git a/src/bot/command_guard.py b/src/bot/command_guard.py index bf291050..f22cd8dc 100644 --- a/src/bot/command_guard.py +++ b/src/bot/command_guard.py @@ -1,61 +1,75 @@ from __future__ import annotations +import os 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.""" + """Unified command gate: group membership + points charge.""" - def __init__(self, io_layer: BotIOLayer, entitlement_service: BotEntitlementService): + _ALLOWED_MEMBER_STATUSES = {"creator", "administrator", "member", "restricted"} + + def __init__(self, io_layer: BotIOLayer, group_chat_id: str | None = None): self.io_layer = io_layer - self.entitlement_service = entitlement_service + self.group_chat_id = str(group_chat_id or os.getenv("TELEGRAM_CHAT_ID") or "").strip() + self.group_invite_url = str(os.getenv("POLYWEATHER_BOT_GROUP_INVITE_URL") or "").strip() - def ensure_entitled(self, message: Any, command_label: str) -> bool: + def _reply_group_required(self, message: Any) -> None: + lines = ["🔒 该指令仅限群成员使用,请先加入官方群。"] + if self.group_invite_url: + lines.append(f"👉 入群链接: {self.group_invite_url}") + self.io_layer.bot.reply_to(message, "\n".join(lines)) + + def ensure_group_member(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: + if not self.group_chat_id: + logger.error( + "group member blocked command={} user_id={} reason=missing_TELEGRAM_CHAT_ID", + command_label, + user_id, + ) + self.io_layer.bot.reply_to( + message, + "⚠️ 机器人未配置群组准入(TELEGRAM_CHAT_ID),请联系管理员。", + ) + return False + + try: + member = self.io_layer.bot.get_chat_member(self.group_chat_id, int(user_id)) + status = str(getattr(member, "status", "") or "").strip().lower() + except Exception as exc: + logger.info( + "group member blocked command={} user_id={} chat_id={} reason=lookup_failed error={}", + command_label, + user_id, + self.group_chat_id, + exc, + ) + self._reply_group_required(message) + return False + + if status in self._ALLOWED_MEMBER_STATUSES: return True - if decision.reason == "bind_required": - denial_text = ( - "🔒 当前指令需要订阅权限。\n" - "请先绑定账号后再试:\n" - "/bind <supabase_user_id> [email]" - ) - elif decision.reason in {"supabase_subscription_required", "premium_required"}: - denial_text = ( - "🔒 当前指令需要高级权限。\n" - "请先开通订阅后再使用。" - ) - else: - denial_text = ( - "🔒 当前指令需要高级权限。\n" - "请先开通订阅后再使用。" - ) - - self.io_layer.bot.reply_to( - message, - denial_text, - parse_mode="HTML", - ) logger.info( - "bot entitlement blocked command={} user_id={} reason={}", + "group member blocked command={} user_id={} chat_id={} status={}", command_label, user_id, - decision.reason, + self.group_chat_id, + status or "unknown", ) + self._reply_group_required(message) return False def ensure_access_and_points(self, message: Any, cost: int, command_label: str) -> bool: - if not self.ensure_entitled(message, command_label): + if not self.ensure_group_member(message, command_label): return False return self.io_layer.ensure_query_points(message, cost, command_label) diff --git a/src/bot/io_layer.py b/src/bot/io_layer.py index f0a1a9ae..f0b64c83 100644 --- a/src/bot/io_layer.py +++ b/src/bot/io_layer.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from typing import Any from loguru import logger @@ -60,6 +61,7 @@ class BotIOLayer: "/id - 获取当前聊天的 Chat ID\n\n" "/diag - 查看 Bot 启动诊断\n\n" "/bind - 绑定 Supabase 账号(可选)\n\n" + "🔐 /city 与 /deb 仅限官方群成员使用。\n\n" "示例: /city 伦敦\n" f"💡 提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分," f"每日上限 {MESSAGE_DAILY_CAP} 分。" @@ -68,21 +70,34 @@ class BotIOLayer: 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() + iso_year, iso_week, _ = now.isocalendar() + week_key = f"{iso_year}-W{iso_week:02d}" - leaderboard = self.db.get_leaderboard(limit=5) - rank_text = "🏆 PolyWeather 活跃度排行榜\n" + leaderboard = self.db.get_weekly_leaderboard(limit=5) + rank_text = f"🏆 PolyWeather 周活跃度排行榜 ({week_key})\n" rank_text += "────────────────────\n" for i, entry in enumerate(leaderboard): medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " " - rank_text += f"{medal} {entry['username'][:12]}: {entry['points']} 点\n" + username = (entry.get("username") or "unknown")[:12] + weekly_points = int(entry.get("weekly_points") or 0) + rank_text += f"{medal} {username}: {weekly_points} 点\n" if user_info: + daily_points = int(user_info.get("daily_points") or 0) + if daily_points > MESSAGE_DAILY_CAP: + daily_points = MESSAGE_DAILY_CAP + weekly_points = int(user_info.get("weekly_points") or 0) + weekly_points_week = str(user_info.get("weekly_points_week") or "") + if weekly_points_week != week_key: + weekly_points = 0 rank_text += "────────────────────\n" rank_text += ( "👤 我的状态:\n" f"┣ 积分: {user_info['points']}\n" f"┣ 发言: {user_info['message_count']} 次\n" - f"┣ 今日发言积分: {user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}\n" + f"┣ 本周发言积分: {weekly_points}\n" + f"┣ 今日发言积分: {daily_points}/{MESSAGE_DAILY_CAP}\n" f"┗ /city 消耗: {CITY_QUERY_COST} | /deb 消耗: {DEB_QUERY_COST}" ) return rank_text @@ -108,7 +123,8 @@ class BotIOLayer: min_text_length=MESSAGE_MIN_LENGTH, ) if result.get("awarded"): + awarded = int(result.get("points_added") or MESSAGE_POINTS) logger.info( - f"message points awarded user={user.id} points=+{MESSAGE_POINTS} " + f"message points awarded user={user.id} points=+{awarded} " f"daily_points={result.get('daily_points')}/{MESSAGE_DAILY_CAP}" ) diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index 79bbae39..145ab22e 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -16,7 +16,6 @@ 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: @@ -61,15 +60,15 @@ def start_bot() -> None: 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) + 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, - entitlement_enabled=entitlement_service.enabled, - protected_commands=sorted(entitlement_service.protected_commands), + command_access_mode="group_member_only", + protected_commands=["/city", "/deb"], + required_group_chat_id=str(os.getenv("TELEGRAM_CHAT_ID") or "").strip(), ) _register_handlers( @@ -84,9 +83,7 @@ def start_bot() -> None: 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)), + "🤖 Bot 启动中... access=group-member-only protected_commands=/city,/deb loops_started={}/{}", started_count, len(runtime_status.loops), ) diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index 27438ffb..0d728a66 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -44,8 +44,9 @@ class LoopStatus: class RuntimeStatus: started_at: str loops: List[LoopStatus] - entitlement_enabled: bool + command_access_mode: str protected_commands: List[str] + required_group_chat_id: str def loop_map(self) -> Dict[str, LoopStatus]: return {loop.key: loop for loop in self.loops} @@ -58,18 +59,21 @@ class StartupCoordinator: self, bot: Any, config: Dict[str, Any], - entitlement_enabled: bool, + command_access_mode: str, protected_commands: List[str], + required_group_chat_id: str, ): self.bot = bot self.config = config - self.entitlement_enabled = entitlement_enabled + self.command_access_mode = command_access_mode self.protected_commands = protected_commands + self.required_group_chat_id = required_group_chat_id self._runtime_status = RuntimeStatus( started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), loops=[], - entitlement_enabled=entitlement_enabled, + command_access_mode=command_access_mode, protected_commands=protected_commands, + required_group_chat_id=required_group_chat_id, ) def get_runtime_status(self) -> RuntimeStatus: @@ -84,8 +88,9 @@ class StartupCoordinator: self._runtime_status = RuntimeStatus( started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), loops=loops, - entitlement_enabled=self.entitlement_enabled, + command_access_mode=self.command_access_mode, protected_commands=self.protected_commands, + required_group_chat_id=self.required_group_chat_id, ) return self._runtime_status @@ -219,8 +224,9 @@ def render_runtime_status_html(status: RuntimeStatus) -> str: "🧭 Bot 启动诊断", f"启动时间: {status.started_at}", "", - f"Entitlement: {'ON' if status.entitlement_enabled else 'OFF'}", + f"命令准入: {status.command_access_mode}", f"受保护命令: {', '.join(status.protected_commands) or '--'}", + f"目标群组: {status.required_group_chat_id or '--'}", "", "后台循环:", ] diff --git a/src/bot/settings.py b/src/bot/settings.py index c5bf5f94..4fd95cc4 100644 --- a/src/bot/settings.py +++ b/src/bot/settings.py @@ -1,7 +1,6 @@ MESSAGE_POINTS = 4 -MESSAGE_DAILY_CAP = 50 -MESSAGE_MIN_LENGTH = 2 +MESSAGE_DAILY_CAP = 10 +MESSAGE_MIN_LENGTH = 3 MESSAGE_COOLDOWN_SEC = 30 CITY_QUERY_COST = 1 DEB_QUERY_COST = 1 - diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 4dce7f49..b890377a 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -1,5 +1,6 @@ import sqlite3 import os +import hashlib from datetime import datetime, timedelta from typing import Optional, Dict, Any from loguru import logger @@ -43,8 +44,19 @@ class DBManager: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS activity_fingerprints ( + telegram_id INTEGER NOT NULL, + activity_date TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (telegram_id, activity_date, fingerprint) + ) + """) self._ensure_column(conn, "users", "daily_points", "INTEGER DEFAULT 0") self._ensure_column(conn, "users", "daily_points_date", "TEXT") + self._ensure_column(conn, "users", "weekly_points", "INTEGER DEFAULT 0") + self._ensure_column(conn, "users", "weekly_points_week", "TEXT") self._ensure_column(conn, "users", "supabase_user_id", "TEXT") self._ensure_column(conn, "users", "supabase_email", "TEXT") conn.commit() @@ -115,16 +127,25 @@ class DBManager: ) -> Dict[str, Any]: """Award points for valid group activity with cooldown and daily cap.""" now = datetime.now() - normalized = "".join((text or "").split()) + normalized = "".join((text or "").split()).lower() if len(normalized) < min_text_length: return {"awarded": False, "reason": "too_short"} + fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest() today_str = now.strftime("%Y-%m-%d") + iso_year, iso_week, _ = now.isocalendar() + week_key = f"{iso_year}-W{iso_week:02d}" with self._get_connection() as conn: conn.row_factory = sqlite3.Row + # Keep dedupe table bounded. + stale_day = (now - timedelta(days=14)).strftime("%Y-%m-%d") + conn.execute( + "DELETE FROM activity_fingerprints WHERE activity_date < ?", + (stale_day,), + ) cursor = conn.execute( """ - SELECT points, daily_points, daily_points_date, last_message_at + SELECT points, daily_points, daily_points_date, weekly_points, weekly_points_week, last_message_at FROM users WHERE telegram_id = ? """, (telegram_id,), @@ -133,6 +154,18 @@ class DBManager: if not row: return {"awarded": False, "reason": "user_missing"} + duplicated = conn.execute( + """ + SELECT 1 + FROM activity_fingerprints + WHERE telegram_id = ? AND activity_date = ? AND fingerprint = ? + LIMIT 1 + """, + (telegram_id, today_str, fingerprint), + ).fetchone() + if duplicated: + return {"awarded": False, "reason": "duplicate_content"} + last_message_at = row["last_message_at"] if last_message_at: last_at = datetime.fromisoformat(last_message_at) @@ -143,18 +176,49 @@ class DBManager: daily_points_date = row["daily_points_date"] or "" if daily_points_date != today_str: daily_points = 0 + # Guard against historical overflow values (legacy bug). + if daily_points > daily_cap: + daily_points = daily_cap + + weekly_points = int(row["weekly_points"] or 0) + weekly_points_week = row["weekly_points_week"] or "" + if weekly_points_week != week_key: + weekly_points = 0 if daily_points >= daily_cap: conn.execute( """ UPDATE users - SET last_message_at = ?, daily_points = ?, daily_points_date = ? + SET last_message_at = ?, daily_points = ?, daily_points_date = ?, + weekly_points = ?, weekly_points_week = ? WHERE telegram_id = ? """, - (now.isoformat(), daily_points, today_str, telegram_id), + ( + now.isoformat(), + daily_points, + today_str, + weekly_points, + week_key, + telegram_id, + ), ) conn.commit() - return {"awarded": False, "reason": "daily_cap", "daily_points": daily_points} + return { + "awarded": False, + "reason": "daily_cap", + "daily_points": daily_points, + "weekly_points": weekly_points, + } + + remaining = max(0, daily_cap - daily_points) + points_added = min(max(0, points_to_add), remaining) + if points_added <= 0: + return { + "awarded": False, + "reason": "daily_cap", + "daily_points": daily_points, + "weekly_points": weekly_points, + } conn.execute(""" UPDATE users @@ -162,14 +226,35 @@ class DBManager: points = points + ?, daily_points = ?, daily_points_date = ?, + weekly_points = ?, + weekly_points_week = ?, last_message_at = ? WHERE telegram_id = ? - """, (points_to_add, daily_points + points_to_add, today_str, now.isoformat(), telegram_id)) + """, ( + points_added, + daily_points + points_added, + today_str, + weekly_points + points_added, + week_key, + now.isoformat(), + telegram_id, + )) + conn.execute( + """ + INSERT OR IGNORE INTO activity_fingerprints + (telegram_id, activity_date, fingerprint) + VALUES (?, ?, ?) + """, + (telegram_id, today_str, fingerprint), + ) conn.commit() return { "awarded": True, "reason": "ok", - "daily_points": daily_points + points_to_add, + "points_added": points_added, + "daily_points": daily_points + points_added, + "weekly_points": weekly_points + points_added, + "weekly_week": week_key, } def spend_points(self, telegram_id: int, amount: int) -> Dict[str, Any]: @@ -221,3 +306,27 @@ class DBManager: LIMIT ? """, (limit,)) return [dict(row) for row in cursor.fetchall()] + + def get_weekly_leaderboard(self, limit: int = 10): + now = datetime.now() + iso_year, iso_week, _ = now.isocalendar() + week_key = f"{iso_year}-W{iso_week:02d}" + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute( + """ + SELECT + username, + points, + message_count, + CASE + WHEN weekly_points_week = ? THEN COALESCE(weekly_points, 0) + ELSE 0 + END AS weekly_points + FROM users + ORDER BY weekly_points DESC, points DESC, message_count DESC + LIMIT ? + """, + (week_key, limit), + ) + return [dict(row) for row in cursor.fetchall()]