feat: Implement bot orchestration, command handling, and runtime coordination for background services.

This commit is contained in:
2569718930@qq.com
2026-03-13 05:53:41 +08:00
parent d5bbc43b52
commit f7b649bb0a
7 changed files with 206 additions and 64 deletions
+4 -3
View File
@@ -38,9 +38,10 @@ SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8 SUPABASE_HTTP_TIMEOUT_SEC=8
SUPABASE_AUTH_CACHE_TTL_SEC=30 SUPABASE_AUTH_CACHE_TTL_SEC=30
SUPABASE_SUB_CACHE_TTL_SEC=60 SUPABASE_SUB_CACHE_TTL_SEC=60
# Bot command entitlement guard (/city, /deb). Off by default until payment API is wired. # Bot command access guard (/city, /deb):
POLYWEATHER_BOT_REQUIRE_ENTITLEMENT=false # - Pro entitlement removed
POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=false # - 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/*) # Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*)
POLYWEATHER_REQUIRE_ENTITLEMENT=false POLYWEATHER_REQUIRE_ENTITLEMENT=false
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
+46 -32
View File
@@ -1,61 +1,75 @@
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
from loguru import logger from loguru import logger
from src.bot.io_layer import BotIOLayer from src.bot.io_layer import BotIOLayer
from src.bot.services.entitlement_service import BotEntitlementService
class CommandGuard: 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.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 = getattr(message, "from_user", None)
user_id = getattr(user, "id", None) user_id = getattr(user, "id", None)
if user_id is None: if user_id is None:
return False return False
decision = self.entitlement_service.check(int(user_id), command_label) if not self.group_chat_id:
if decision.allowed: 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 return True
if decision.reason == "bind_required":
denial_text = (
"🔒 当前指令需要订阅权限。\n"
"请先绑定账号后再试:\n"
"<code>/bind &lt;supabase_user_id&gt; [email]</code>"
)
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( logger.info(
"bot entitlement blocked command={} user_id={} reason={}", "group member blocked command={} user_id={} chat_id={} status={}",
command_label, command_label,
user_id, user_id,
decision.reason, self.group_chat_id,
status or "unknown",
) )
self._reply_group_required(message)
return False return False
def ensure_access_and_points(self, message: Any, cost: int, command_label: str) -> bool: 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 False
return self.io_layer.ensure_query_points(message, cost, command_label) return self.io_layer.ensure_query_points(message, cost, command_label)
+21 -5
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Any from typing import Any
from loguru import logger from loguru import logger
@@ -60,6 +61,7 @@ class BotIOLayer:
"/id - 获取当前聊天的 Chat ID\n\n" "/id - 获取当前聊天的 Chat ID\n\n"
"/diag - 查看 Bot 启动诊断\n\n" "/diag - 查看 Bot 启动诊断\n\n"
"/bind - 绑定 Supabase 账号(可选)\n\n" "/bind - 绑定 Supabase 账号(可选)\n\n"
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
"示例: <code>/city 伦敦</code>\n" "示例: <code>/city 伦敦</code>\n"
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分," f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>" f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
@@ -68,21 +70,34 @@ class BotIOLayer:
def build_points_rank_text(self, user: Any) -> str: def build_points_rank_text(self, user: Any) -> str:
self.db.upsert_user(user.id, self.display_name(user)) self.db.upsert_user(user.id, self.display_name(user))
user_info = self.db.get_user(user.id) 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) leaderboard = self.db.get_weekly_leaderboard(limit=5)
rank_text = "🏆 <b>PolyWeather 活跃度排行榜</b>\n" rank_text = f"🏆 <b>PolyWeather 活跃度排行榜 ({week_key})</b>\n"
rank_text += "────────────────────\n" rank_text += "────────────────────\n"
for i, entry in enumerate(leaderboard): for i, entry in enumerate(leaderboard):
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " " medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
rank_text += f"{medal} {entry['username'][:12]}: <b>{entry['points']}</b> 点\n" username = (entry.get("username") or "unknown")[:12]
weekly_points = int(entry.get("weekly_points") or 0)
rank_text += f"{medal} {username}: <b>{weekly_points}</b> 点\n"
if user_info: 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"
rank_text += ( rank_text += (
"👤 <b>我的状态:</b>\n" "👤 <b>我的状态:</b>\n"
f"┣ 积分: <code>{user_info['points']}</code>\n" f"┣ 积分: <code>{user_info['points']}</code>\n"
f"┣ 发言: <code>{user_info['message_count']}</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"本周发言积分: <code>{weekly_points}</code>\n"
f"┣ 今日发言积分: <code>{daily_points}/{MESSAGE_DAILY_CAP}</code>\n"
f"┗ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>" f"┗ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
) )
return rank_text return rank_text
@@ -108,7 +123,8 @@ class BotIOLayer:
min_text_length=MESSAGE_MIN_LENGTH, min_text_length=MESSAGE_MIN_LENGTH,
) )
if result.get("awarded"): if result.get("awarded"):
awarded = int(result.get("points_added") or MESSAGE_POINTS)
logger.info( 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}" f"daily_points={result.get('daily_points')}/{MESSAGE_DAILY_CAP}"
) )
+5 -8
View File
@@ -16,7 +16,6 @@ from src.bot.io_layer import BotIOLayer
from src.bot.runtime_coordinator import StartupCoordinator from src.bot.runtime_coordinator import StartupCoordinator
from src.bot.services.city_command_service import CityCommandService from src.bot.services.city_command_service import CityCommandService
from src.bot.services.deb_command_service import DebCommandService from src.bot.services.deb_command_service import DebCommandService
from src.bot.services.entitlement_service import BotEntitlementService
def _project_root() -> str: def _project_root() -> str:
@@ -61,15 +60,15 @@ def start_bot() -> None:
io_layer = BotIOLayer(bot=bot, db=db) io_layer = BotIOLayer(bot=bot, db=db)
city_analysis = CityAnalysisService(weather=weather) city_analysis = CityAnalysisService(weather=weather)
deb_analysis = DebAnalysisService(project_root=_project_root()) deb_analysis = DebAnalysisService(project_root=_project_root())
entitlement_service = BotEntitlementService(db=db) guard = CommandGuard(io_layer=io_layer)
guard = CommandGuard(io_layer=io_layer, entitlement_service=entitlement_service)
city_service = CityCommandService(analysis=city_analysis) city_service = CityCommandService(analysis=city_analysis)
deb_service = DebCommandService(analysis=deb_analysis) deb_service = DebCommandService(analysis=deb_analysis)
startup_coordinator = StartupCoordinator( startup_coordinator = StartupCoordinator(
bot=bot, bot=bot,
config=config, config=config,
entitlement_enabled=entitlement_service.enabled, command_access_mode="group_member_only",
protected_commands=sorted(entitlement_service.protected_commands), protected_commands=["/city", "/deb"],
required_group_chat_id=str(os.getenv("TELEGRAM_CHAT_ID") or "").strip(),
) )
_register_handlers( _register_handlers(
@@ -84,9 +83,7 @@ def start_bot() -> None:
started_count = sum(1 for loop in runtime_status.loops if loop.started) started_count = sum(1 for loop in runtime_status.loops if loop.started)
logger.info( logger.info(
"🤖 Bot 启动中... entitlement_enabled={} protected_commands={} loops_started={}/{}", "🤖 Bot 启动中... access=group-member-only protected_commands=/city,/deb loops_started={}/{}",
entitlement_service.enabled,
",".join(sorted(entitlement_service.protected_commands)),
started_count, started_count,
len(runtime_status.loops), len(runtime_status.loops),
) )
+12 -6
View File
@@ -44,8 +44,9 @@ class LoopStatus:
class RuntimeStatus: class RuntimeStatus:
started_at: str started_at: str
loops: List[LoopStatus] loops: List[LoopStatus]
entitlement_enabled: bool command_access_mode: str
protected_commands: List[str] protected_commands: List[str]
required_group_chat_id: str
def loop_map(self) -> Dict[str, LoopStatus]: def loop_map(self) -> Dict[str, LoopStatus]:
return {loop.key: loop for loop in self.loops} return {loop.key: loop for loop in self.loops}
@@ -58,18 +59,21 @@ class StartupCoordinator:
self, self,
bot: Any, bot: Any,
config: Dict[str, Any], config: Dict[str, Any],
entitlement_enabled: bool, command_access_mode: str,
protected_commands: List[str], protected_commands: List[str],
required_group_chat_id: str,
): ):
self.bot = bot self.bot = bot
self.config = config self.config = config
self.entitlement_enabled = entitlement_enabled self.command_access_mode = command_access_mode
self.protected_commands = protected_commands self.protected_commands = protected_commands
self.required_group_chat_id = required_group_chat_id
self._runtime_status = RuntimeStatus( self._runtime_status = RuntimeStatus(
started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
loops=[], loops=[],
entitlement_enabled=entitlement_enabled, command_access_mode=command_access_mode,
protected_commands=protected_commands, protected_commands=protected_commands,
required_group_chat_id=required_group_chat_id,
) )
def get_runtime_status(self) -> RuntimeStatus: def get_runtime_status(self) -> RuntimeStatus:
@@ -84,8 +88,9 @@ class StartupCoordinator:
self._runtime_status = RuntimeStatus( self._runtime_status = RuntimeStatus(
started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
loops=loops, loops=loops,
entitlement_enabled=self.entitlement_enabled, command_access_mode=self.command_access_mode,
protected_commands=self.protected_commands, protected_commands=self.protected_commands,
required_group_chat_id=self.required_group_chat_id,
) )
return self._runtime_status return self._runtime_status
@@ -219,8 +224,9 @@ def render_runtime_status_html(status: RuntimeStatus) -> str:
"🧭 <b>Bot 启动诊断</b>", "🧭 <b>Bot 启动诊断</b>",
f"启动时间: <code>{status.started_at}</code>", f"启动时间: <code>{status.started_at}</code>",
"", "",
f"Entitlement: <code>{'ON' if status.entitlement_enabled else 'OFF'}</code>", f"命令准入: <code>{status.command_access_mode}</code>",
f"受保护命令: <code>{', '.join(status.protected_commands) or '--'}</code>", f"受保护命令: <code>{', '.join(status.protected_commands) or '--'}</code>",
f"目标群组: <code>{status.required_group_chat_id or '--'}</code>",
"", "",
"后台循环:", "后台循环:",
] ]
+2 -3
View File
@@ -1,7 +1,6 @@
MESSAGE_POINTS = 4 MESSAGE_POINTS = 4
MESSAGE_DAILY_CAP = 50 MESSAGE_DAILY_CAP = 10
MESSAGE_MIN_LENGTH = 2 MESSAGE_MIN_LENGTH = 3
MESSAGE_COOLDOWN_SEC = 30 MESSAGE_COOLDOWN_SEC = 30
CITY_QUERY_COST = 1 CITY_QUERY_COST = 1
DEB_QUERY_COST = 1 DEB_QUERY_COST = 1
+116 -7
View File
@@ -1,5 +1,6 @@
import sqlite3 import sqlite3
import os import os
import hashlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from loguru import logger from loguru import logger
@@ -43,8 +44,19 @@ class DBManager:
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 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", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_points_date", "TEXT") 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_user_id", "TEXT")
self._ensure_column(conn, "users", "supabase_email", "TEXT") self._ensure_column(conn, "users", "supabase_email", "TEXT")
conn.commit() conn.commit()
@@ -115,16 +127,25 @@ class DBManager:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Award points for valid group activity with cooldown and daily cap.""" """Award points for valid group activity with cooldown and daily cap."""
now = datetime.now() now = datetime.now()
normalized = "".join((text or "").split()) normalized = "".join((text or "").split()).lower()
if len(normalized) < min_text_length: if len(normalized) < min_text_length:
return {"awarded": False, "reason": "too_short"} return {"awarded": False, "reason": "too_short"}
fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
today_str = now.strftime("%Y-%m-%d") 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: with self._get_connection() as conn:
conn.row_factory = sqlite3.Row 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( 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 = ? FROM users WHERE telegram_id = ?
""", """,
(telegram_id,), (telegram_id,),
@@ -133,6 +154,18 @@ class DBManager:
if not row: if not row:
return {"awarded": False, "reason": "user_missing"} 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"] last_message_at = row["last_message_at"]
if last_message_at: if last_message_at:
last_at = datetime.fromisoformat(last_message_at) last_at = datetime.fromisoformat(last_message_at)
@@ -143,18 +176,49 @@ class DBManager:
daily_points_date = row["daily_points_date"] or "" daily_points_date = row["daily_points_date"] or ""
if daily_points_date != today_str: if daily_points_date != today_str:
daily_points = 0 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: if daily_points >= daily_cap:
conn.execute( conn.execute(
""" """
UPDATE users 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 = ? 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() 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(""" conn.execute("""
UPDATE users UPDATE users
@@ -162,14 +226,35 @@ class DBManager:
points = points + ?, points = points + ?,
daily_points = ?, daily_points = ?,
daily_points_date = ?, daily_points_date = ?,
weekly_points = ?,
weekly_points_week = ?,
last_message_at = ? last_message_at = ?
WHERE telegram_id = ? 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() conn.commit()
return { return {
"awarded": True, "awarded": True,
"reason": "ok", "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]: def spend_points(self, telegram_id: int, amount: int) -> Dict[str, Any]:
@@ -221,3 +306,27 @@ class DBManager:
LIMIT ? LIMIT ?
""", (limit,)) """, (limit,))
return [dict(row) for row in cursor.fetchall()] 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()]