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_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=
+46 -32
View File
@@ -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"
"<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(
"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)
+21 -5
View File
@@ -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"
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
"示例: <code>/city 伦敦</code>\n"
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
@@ -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 = "🏆 <b>PolyWeather 活跃度排行榜</b>\n"
leaderboard = self.db.get_weekly_leaderboard(limit=5)
rank_text = f"🏆 <b>PolyWeather 活跃度排行榜 ({week_key})</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"
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:
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 += (
"👤 <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"本周发言积分: <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>"
)
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}"
)
+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.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),
)
+12 -6
View File
@@ -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:
"🧭 <b>Bot 启动诊断</b>",
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>{status.required_group_chat_id or '--'}</code>",
"",
"后台循环:",
]
+2 -3
View File
@@ -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
+116 -7
View File
@@ -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()]